Skip to content

Commit 7e7fab7

Browse files
fix(rest,lint): refuse an import mapping target that names no field, on the dry run, the commit and validate alike (#20208)
Fixes #20150 Clause-②: yes (narrowing) ## What this does `ImportFieldMappingSchema.target` is declared as "Target object field(s)". A mapping whose target named no field of its `targetObject` passed `objectstack validate`, answered `ok` on every row of the dry run, and then failed every row on the commit with `INVALID_FIELD`. The dry run promised what the commit refused (#4633's contract is that the dry run predicts the write). One verdict now decides what a target may name, and both doors call it: - **`@objectstack/spec`** (`packages/spec/src/data/import-mapping-target.ts`, re-exported from `mapping.zod.ts` beside the schema it judges): `unknownImportMappingTargets(fieldMapping, objectDef)`, built on `indexImportMappingTargets` + `judgeImportMappingTarget` + `importMappingEntryTargets`. It lives in the spec because `@objectstack/rest` and `@objectstack/lint` both depend on the spec and neither depends on the other (triage `5853335141`). - **The import door** (`packages/rest/src/import-prepare.ts`, `import-mapping.ts`): `prepareImportRequest` resolves the object definition first and refuses a named mapping with a target that names no field BEFORE any row, with `400 INVALID_FIELD`. Both the dry run and the commit arrive through that function, so they give the same answer (the async import-job route shares it too). `rest-server.ts` is untouched. - **The author-time check** (`packages/lint/src/validate-mapping-target-fields.ts`): a new reference-integrity suite member, `validateMappingTargetFields`, rule `mapping-target-field-unknown`, severity `error`, located at `mappings[i].fieldMapping[j].target` and naming the mapping and the object. The suite is the one table `os validate`, `os lint` and `os build` share, so there is no validate-only copy in `packages/cli`. ### What a target may name (and the false-refusal trap) A target resolves when it names (1) a declared field, (2) a column the platform provisions on THAT object, from `resolveInjectedSystemColumns` (`owner_id` only where the object carries it, and so on), or (3) `id` / `created_at` / `updated_at`, which the engine's write door (`PLATFORM_PROVISIONED_COLUMNS` in `undeclaredWriteFieldErrors`) admits on every object, `systemFields: false` included. Row 3 is what keeps the new refusal from being narrower than the write: measured below, a `created_at` target on a `systemFields: false` object is written by the commit. An object with no readable, non-empty field map is not judged at all (no opinion, never a refusal), the engine's own position on the same input. Measured at the door (probe against the real engine + protocol + sqlite, HEAD `eab27ef780`): the object definition `getMetaItem` serves carries the injected columns (`created_at, created_by, organization_id, owner_id, owning_business_unit_id, updated_at, updated_by`) and the fields an `objectExtensions` entry merges in. The lint side reads the authored stack, so it folds `objectExtensions` fields (from the stack and from `packages[].manifest`) into the object before judging, and it skips a mapping whose `targetObject` this stack does not define, the same skip its field-existence siblings take. ### Where #20149's arm goes #20149 is not addressed here (ruled A, `Blocked-by` this card). Its compound-part capability extends this verdict by ONE arm in `judgeImportMappingTarget`, at the marked comment, returning a new `kind: 'part'` member of `ImportMappingTargetVerdict`; it reads the head field's definition from `ImportMappingTargetIndex.fields`, which carries the declared definitions for exactly that purpose. It never becomes a second predicate. Until it lands, `mailing_address.street` names no field and is refused, which is this card's pin. ## Before and after (measured) Before, with the door's refusal ablated at HEAD (the same door origin/main runs), mapping target `mailing_address.street` on object `task`: - dry run: `200`, `ok: 2, errors: 0` - commit: `200`, `ok: 0, errors: 2`, each row `INVALID_FIELD` "Unknown field 'mailing_address.street' on object 'task'" After: both answer `400` `{ code: 'INVALID_FIELD' }`, byte-identical bodies, nothing written. The refusal reuses the code the commit already carried, so no error-code ledger entry is added. ## Tests The card's pin is in `packages/rest/src/import-integration.test.ts` (real engine, real protocol, sqlite): a target that names no field is refused on the dry run and on the commit alike; each element of a `split` target is judged; a valid mapping is the control (dry run and commit agree on `ok`); and a platform column the door accepts (`created_at` / `updated_at` on a `systemFields: false` object, `owner_id` on a default one) stays green at both ends. The lint rule is red on the bad mapping and green on the control (`validate-mapping-target-fields.test.ts`), and the suite membership pin gains the member with a live finding (`reference-integrity-suite.test.ts`). The verdict itself is pinned in `packages/spec/src/data/import-mapping-target.test.ts`. Local results at `f35d38b16d` (all through `os-verify-lock.sh`, shared box): - `@objectstack/spec`: `vitest run --project local` 541 files / 15849 tests pass; `typecheck` (tsc, scripts, test layer) exit 0; `check:generated` all 15 artifacts up to date after `gen:api-surface` + `gen:export-origins` (committed). - `@objectstack/lint`: `vitest run` 109 files / 4183 tests pass; `typecheck` exit 0. - `@objectstack/rest`: `vitest run --project local` 197 files / 3347 pass, 1 skipped; `typecheck` exit 0. - `@objectstack/cli` (unit): `test/validate-build-gate-parity.test.ts` 22 pass. The `integration` layer (the spawned-CLI `authoring-rule-command-parity`) is declared to CI. - Gates: `dispatch-gates.mjs --commands` derived 85 families; `--ran` reconciles 83 run green, 2 NOT MEASURED, 0 unrun. NOT MEASURED: `check:dual-build-cjs-loads` and `check:type-check-debt`, both PREREQUISITE NOT MET (exit 3, they read a whole-repo build this worktree does not have). - ESLint over the 12 changed `.ts` files (`--no-inline-config`): 0 errors, 0 warnings. The repo-wide `pnpm lint` is CI's. ### Ablation (one-shot, restored and proven byte-identical to HEAD) Each leg mutated through `scripts/ablation-replace.mjs` (anchor hit 1, blob changed, restored by `git checkout HEAD`, hash equal to the HEAD blob after): - A, the door stops refusing (`import-prepare.ts`): the two refusal tests in `import-integration.test.ts` go red; the controls stay green. - B, the lint rule stops reporting: 3 rule tests and the suite's "every member runs" test go red. - C, the spec verdict admits every name: 3 verdict tests go red. ## Acceptance notes - `content/docs/data-modeling/import-mappings.mdx` is not edited: its "Rejections before any row is read" table does not list the new `INVALID_FIELD` row and its build-validation list does not mention the new lint rule. No sentence in it turns false, so it is outside this card's surface (its frontmatter is held by PR #20170). Carrier: none. - The engine keeps its own `PLATFORM_PROVISIONED_COLUMNS` literal in `@objectstack/objectql`; the spec constant `IMPORT_TARGET_ALWAYS_ADDRESSABLE_COLUMNS` mirrors it. Converging the engine onto the spec constant is outside this surface. Carrier: none. - The runtime publish gate does not judge `mapping` writes: the new member keeps the frozen `flow` default, and a flow snapshot carries no `mappings`. The import door refuses a bad mapping at use either way. - `upsertKey` naming no field is not judged here (the card is about `target`). --- _Generated by [Claude Code](https://claude.ai/code/session_01QcAS3qiYYZNezaxZxaUdMV)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 585c9af commit 7e7fab7

15 files changed

Lines changed: 983 additions & 12 deletions
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
---
2+
"@objectstack/spec": minor
3+
"@objectstack/rest": minor
4+
"@objectstack/lint": minor
5+
---
6+
7+
fix(spec,rest,lint): an import mapping target that names no field is refused on the dry run, on the commit and at `objectstack validate` alike (#20150)
8+
9+
Clause-②: yes (narrowing)
10+
11+
<!-- adr-0087: not-required (no-migration-prescription) Nothing authorable is renamed, retired or re-typed: `ImportFieldMappingSchema.target` keeps its declared shape and meaning ("Target object field(s)"), no stored mapping or record moves, and every mapping parses byte-identically to before. What narrows is the ACCEPT SET of the import door and of the published author-time checker, and only for mappings whose target already names no field of the object: those failed every row on the commit before this change, so no import that wrote anything is refused now. The remedy is per mapping and the refusal names it in full (the mapping, the target, its position and the object); there is no stored representation for `objectstack migrate meta` to rewrite. -->
12+
13+
**BREAKING** in the accept-set sense only, landing in the launch window as
14+
`minor`: the import route and `objectstack validate` now refuse a mapping they
15+
used to pass, and every such mapping already failed on the commit.
16+
17+
**What was wrong.** `ImportFieldMappingSchema.target` is declared as "Target
18+
object field(s)", and nothing held a mapping to it. A mapping whose target named
19+
no field of its `targetObject` (measured with `mailing_address.street` on an
20+
object whose address field is `mailing_address`):
21+
22+
- passed `objectstack validate`, `os lint` and `os build` with no diagnostic;
23+
- answered `ok` for every row on `POST /api/v1/data/:object/import` with
24+
`dryRun: true`;
25+
- then failed every row on the commit with `INVALID_FIELD` ("Unknown field
26+
'mailing_address.street' on object '…'").
27+
28+
The dry run promised what the commit refused.
29+
30+
**What changes.**
31+
32+
- `@objectstack/spec` exports ONE verdict on what a target may name, beside the
33+
schema it judges: `unknownImportMappingTargets(fieldMapping, objectDef)`, with
34+
`indexImportMappingTargets`, `judgeImportMappingTarget`,
35+
`importMappingEntryTargets` and `IMPORT_TARGET_ALWAYS_ADDRESSABLE_COLUMNS`
36+
(from `@objectstack/spec/data`). A target may name a declared field, a column
37+
the platform provisions on that object (`resolveInjectedSystemColumns`), or one
38+
of `id` / `created_at` / `updated_at`, which the engine's write door admits on
39+
every object. An object with no readable, non-empty field map is not judged.
40+
- `@objectstack/rest`: `prepareImportRequest` refuses a named mapping (`mappingName`)
41+
with a target that names no field, before any row, with `400 INVALID_FIELD` —
42+
the code the commit's per-row refusal already carried. The dry run and the
43+
commit give the same answer, and so does the async import-job route.
44+
- `@objectstack/lint`: the reference-integrity suite (`os validate`, `os lint`,
45+
`os build`) gains `validateMappingTargetFields`, rule id
46+
`mapping-target-field-unknown` (`MAPPING_TARGET_FIELD_UNKNOWN`), severity
47+
`error`, located at `mappings[i].fieldMapping[j].target`. It asks the same
48+
spec verdict, so it never refuses a target the import door accepts.
49+
50+
**What to do.** Point each reported target at a field the object declares. An
51+
array target (`split`) is judged element by element.

‎packages/lint/src/index.ts‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -594,6 +594,15 @@ export {
594594
} from './validate-dataset-references.js';
595595
export type { DatasetRefFinding, DatasetRefSeverity } from './validate-dataset-references.js';
596596

597+
// [#20150] An import mapping's `fieldMapping[].target` must name a field of its
598+
// `targetObject` — judged by the spec's `unknownImportMappingTargets`, the same
599+
// verdict the import door refuses with.
600+
export {
601+
validateMappingTargetFields,
602+
MAPPING_TARGET_FIELD_UNKNOWN,
603+
} from './validate-mapping-target-fields.js';
604+
export type { MappingTargetFieldFinding } from './validate-mapping-target-fields.js';
605+
597606
// The two reusable seams the rule above is written on, exported because the
598607
// queued siblings (#14148 widget filter keys + sortBy, #14107 list-view field
599608
// positions) must reuse ONE mechanism rather than growing a second hop-walker

‎packages/lint/src/reference-integrity-suite.test.ts‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,11 @@ describe('reference-integrity suite — membership', () => {
4949
// rules above it — the conscious edit this written-out list exists to
5050
// force, placed so the family reports top-down.
5151
'validateDatasetReferences',
52+
// [#20150] An import mapping's targets, resolved against its object's
53+
// fields through the spec verdict the import door refuses with. Placed
54+
// after the dataset member: both resolve field names written on a
55+
// collection other than the object itself.
56+
'validateMappingTargetFields',
5257
'validateNavAccess',
5358
'validateNavTargetRefs',
5459
// [#17063] `validateViewPageRefs` sat HERE, beside the nav twin that asks
@@ -232,6 +237,13 @@ describe('reference-integrity suite — every member actually runs', () => {
232237
measures: [{ name: 'count_leads', aggregate: 'count' }],
233238
},
234239
],
240+
// validateMappingTargetFields (#20150): `budget` is not a field on
241+
// crm_lead, so the import door refuses this mapping before any row, on the
242+
// dry run and the commit alike. Its own collection, so no other member
243+
// can go silent behind it.
244+
mappings: [
245+
{ name: 'lead_import', targetObject: 'crm_lead', fieldMapping: [{ source: 'Budget', target: 'budget' }] },
246+
],
235247
reports: [
236248
{
237249
name: 'leads_by_source',
@@ -353,6 +365,7 @@ describe('reference-integrity suite — every member actually runs', () => {
353365
expect(rules).toContain('page-field-unknown');
354366
expect(rules).toContain('chart-measure-unknown');
355367
expect(rules).toContain('dataset-field-unknown');
368+
expect(rules).toContain('mapping-target-field-unknown');
356369
expect(rules).toContain('nav-object-ungranted');
357370
expect(rules).toContain('nav-object-unservable');
358371
expect(rules).toContain('translation-target-unknown');

‎packages/lint/src/reference-integrity-suite.ts‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,7 @@ import { validatePageFieldBindings } from './validate-page-field-bindings.js';
110110
import { validatePageVisualizationBindings } from './validate-page-visualization-bindings.js';
111111
import { validateChartBindings } from './validate-chart-bindings.js';
112112
import { validateDatasetReferences } from './validate-dataset-references.js';
113+
import { validateMappingTargetFields } from './validate-mapping-target-fields.js';
113114
import { validateNavAccess } from './validate-nav-access.js';
114115
import { validateNavTargetRefs } from './validate-nav-target-refs.js';
115116
import { validateNavObjectServability } from './validate-nav-object-servability.js';
@@ -420,6 +421,19 @@ export const REFERENCE_INTEGRITY_RULES: readonly ReferenceIntegrityRule[] = [
420421
// manufacturing a refusal. Crossed TOGETHER with its base-object sibling
421422
// `validateObjectReferences` on #7220's reading, never one without the other.
422423
{ name: 'validateDatasetReferences', runtimeTypes: ['flow', 'dataset'], run: validateDatasetReferences },
424+
// [#20150] An import mapping's `fieldMapping[].target`, resolved against its
425+
// `targetObject`'s fields — the charter's question on the one field-naming
426+
// surface a mapping carries. The verdict is the spec's
427+
// `unknownImportMappingTargets`, the same function the import door refuses
428+
// with, so this member and that door cannot disagree about a name. It gates:
429+
// the door refuses such a mapping before any row, so it imports nothing.
430+
//
431+
// NO `runtimeTypes`, i.e. the frozen `flow` default: it reads
432+
// `stack.mappings`, which no per-write snapshot carries, so on a flow
433+
// snapshot it finds no mapping and contributes nothing. Judging a `mapping`
434+
// write at the runtime publish gate would be its own crossing, with its own
435+
// measurement; at runtime the import door already refuses the mapping.
436+
{ name: 'validateMappingTargetFields', run: validateMappingTargetFields },
423437
{ name: 'validateNavAccess', run: validateNavAccess },
424438
// Nav targets that are NOT object names — page/report/dashboard. Restores the
425439
// coverage `defineStack`'s own cross-reference block switches off whenever the
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { describe, it, expect } from 'vitest';
4+
5+
import {
6+
MAPPING_TARGET_FIELD_UNKNOWN,
7+
validateMappingTargetFields,
8+
} from './validate-mapping-target-fields.js';
9+
10+
// [#20150] The author-time half of the import door's refusal: a mapping target
11+
// that names no field of its object is red at `os validate`, and a mapping
12+
// whose targets resolve (declared fields and provisioned columns alike) is
13+
// green. The verdict is the spec's; this pins the rule's reading of a stack.
14+
15+
const contact = {
16+
name: 'crm_contact',
17+
fields: {
18+
full_name: { type: 'text' },
19+
email: { type: 'email' },
20+
mailing_address: { type: 'address' },
21+
},
22+
};
23+
24+
const mapping = (fieldMapping: unknown[], extra: Record<string, unknown> = {}) => ({
25+
name: 'contact_import',
26+
targetObject: 'crm_contact',
27+
fieldMapping,
28+
...extra,
29+
});
30+
31+
describe('validateMappingTargetFields', () => {
32+
it('reports a target that names no field of the object, located at the mapping (the card\'s shape)', () => {
33+
const findings = validateMappingTargetFields({
34+
objects: [contact],
35+
mappings: [mapping([
36+
{ source: 'Name', target: 'full_name' },
37+
{ source: 'Street', target: 'mailing_address.street' },
38+
])],
39+
});
40+
expect(findings).toHaveLength(1);
41+
expect(findings[0]).toMatchObject({
42+
severity: 'error',
43+
rule: MAPPING_TARGET_FIELD_UNKNOWN,
44+
where: 'mapping "contact_import" · object "crm_contact"',
45+
path: 'mappings[0].fieldMapping[1].target',
46+
});
47+
// Names the target and the object, and says why a dotted path is not one.
48+
expect(findings[0].message).toContain('"mailing_address.street"');
49+
expect(findings[0].message).toContain('"crm_contact"');
50+
expect(findings[0].message).toContain('"mailing_address" is a field of "crm_contact"');
51+
});
52+
53+
it('is green on the control mapping — declared fields only', () => {
54+
expect(validateMappingTargetFields({
55+
objects: [contact],
56+
mappings: [mapping([
57+
{ source: 'Name', target: 'full_name' },
58+
{ source: 'Mail', target: 'email', transform: 'map', params: { valueMap: {} } },
59+
])],
60+
})).toEqual([]);
61+
});
62+
63+
it('is green on a target naming a column the platform provisions on this object', () => {
64+
// The import door accepts these (the registry injects them, the write door
65+
// admits them); the author-time check must never refuse what the door accepts.
66+
expect(validateMappingTargetFields({
67+
objects: [contact],
68+
mappings: [mapping([
69+
{ source: 'Owner', target: 'owner_id', transform: 'lookup' },
70+
{ source: 'Created', target: 'created_at' },
71+
{ source: 'Id', target: 'id' },
72+
])],
73+
})).toEqual([]);
74+
});
75+
76+
it('refuses a provisioned column the object does NOT carry (ownership: none has no owner_id)', () => {
77+
const findings = validateMappingTargetFields({
78+
objects: [{ ...contact, ownership: 'none' }],
79+
mappings: [mapping([{ source: 'Owner', target: 'owner_id' }])],
80+
});
81+
expect(findings.map((f) => f.path)).toEqual(['mappings[0].fieldMapping[0].target']);
82+
});
83+
84+
it('judges every element of an array target', () => {
85+
const findings = validateMappingTargetFields({
86+
objects: [contact],
87+
mappings: [mapping([{ source: 'Full', target: ['full_name', 'nick_name'], transform: 'split' }])],
88+
});
89+
expect(findings.map((f) => f.path)).toEqual(['mappings[0].fieldMapping[0].target[1]']);
90+
expect(findings[0].message).toContain('"nick_name"');
91+
});
92+
93+
it('folds fields an objectExtensions entry merges into the object — from the stack and from a sibling package', () => {
94+
const stack = {
95+
objects: [contact],
96+
objectExtensions: [{ extend: 'crm_contact', fields: { sla_tier: { type: 'text' } } }],
97+
packages: [{ manifest: { objectExtensions: [{ extend: 'crm_contact', fields: { region: { type: 'text' } } }] } }],
98+
mappings: [mapping([
99+
{ source: 'Tier', target: 'sla_tier' },
100+
{ source: 'Region', target: 'region' },
101+
])],
102+
};
103+
expect(validateMappingTargetFields(stack)).toEqual([]);
104+
});
105+
106+
it('stays silent on a mapping whose object this stack does not define (skip 1)', () => {
107+
expect(validateMappingTargetFields({
108+
objects: [contact],
109+
mappings: [{ name: 'user_import', targetObject: 'sys_user', fieldMapping: [{ source: 'x', target: 'zzz' }] }],
110+
})).toEqual([]);
111+
});
112+
113+
it('stays silent on an object with no readable field map (skip 2)', () => {
114+
expect(validateMappingTargetFields({
115+
objects: [{ name: 'ext_orders', external: true }],
116+
mappings: [{ name: 'orders', targetObject: 'ext_orders', fieldMapping: [{ source: 'x', target: 'zzz' }] }],
117+
})).toEqual([]);
118+
});
119+
120+
it('returns nothing for a stack without mappings, and survives junk entries', () => {
121+
expect(validateMappingTargetFields({ objects: [contact] })).toEqual([]);
122+
expect(() => validateMappingTargetFields({ objects: [null, contact], mappings: [null, 7] } as never)).not.toThrow();
123+
});
124+
});

0 commit comments

Comments
 (0)