Repository navigation
Commit 443b2f4
feat(spec,rest,lint): an import mapping target may name a declared part of a compound field (mailing_address.street) (#20246)
Fixes #20149
Clause-②: yes
## What this does
The maintainer's ruling on #20149 (comment 5852138019, 「同意」):
> **Ruled: A — a mapping target may name a declared part of a compound
field (`mailing_address.street`); the importer assembles the parts into
one value; the part names are the closed set the value schema
declares.**
An import mapping could write each source column to one flat field only,
so nothing could build an `address` value from the street / city / state
/ postal code / country columns a spreadsheet carries. Now a
`fieldMapping[].target` may name `field.part`, and the import door
assembles every part one row maps into ONE value under the field's key,
before the engine sees the row.
It extends the ONE verdict #20150 built, at the arm that PR marked.
There is no second predicate.
- **`@objectstack/spec`**
(`packages/spec/src/data/import-mapping-target.ts`):
- `judgeImportMappingTarget` gains the `{ kind: 'part', target, field,
part }` arm.
- `indexImportMappingTargets` carries each compound field's parts on a
new `parts` map, read from the field's stored value schema
(`valueSchemaFor`). The part names are never listed by hand.
- A dotted target that stays refused answers `unknown` with a `head`
(what the text before the dot names, and its parts when it is compound),
so every door can name the legal parts.
- `unknownImportMappingTargets` gives each refused target a `reason`:
`unknown`, or `collides` for a part of a field the same mapping also
writes whole.
- `ImportFieldMappingSchema.target` declares the part path in its
`.describe()` and docblock. The generated reference page, `api-surface/`
and `export-origins/` are regenerated.
- **`@objectstack/rest`** (`import-mapping.ts`, `import-prepare.ts`):
- `applyMappingToRows` takes the object definition, `trimWhitespace` and
`nullValues`, and assembles parts from every transform: `none`, `map`,
`constant`, `join`, and each element of a `split`.
- `refuseUnknownMappingTargets` names the legal parts in its refusal. It
still answers `400 INVALID_FIELD`, before any row.
- `isBlank` is exported from `import-coerce.ts`, so a blank part is
judged by the same rule as a blank cell.
- **`@objectstack/lint`** (`validate-mapping-target-fields.ts`): the
message "a dotted path into a field's value is not a target" is gone,
because it is false for a declared part. The rule now reports the three
refused cases, each with the legal parts.
- **Docs**: in `content/docs/data-modeling/import-mappings.mdx` the
`target` row said "Target field name(s)", which is now false. It is
rewritten. Only the body is edited; the frontmatter is held by PR
#20170.
## Decisions, each from a measurement
**H1: which fields are compound.** I checked every one of the 49
`FieldType` values through `valueSchemaFor`. Two stored value schemas
are closed objects (`catchall: never`):
- `address`: seven parts (`street`, `city`, `state`, `postalCode`,
`country`, `countryCode`, `formatted`). Every part is an optional
string.
- `location`: `lat` and `lng` are required numbers; `altitude` and
`accuracy` are optional numbers.
A field counts as compound when its value schema is a closed object
whose every part is an optional string. That selects `address` only.
**`location` is excluded, as the ruling asked me to decide and record:**
- `LocationValueSchema` refuses `{ lat: '37.7', lng: '-122.4' }`
(strings) and `{ lat: 37.7 }` (no `lng`).
- On the ADR-0104 warn-first write path, the engine admits a value that
fails its value schema and only warns. I measured this below with an
address part holding a number.
- So a location assembled from text cells would be stored with the wrong
type, and nothing would refuse it. With optional-string parts, every
subset of text cells is a valid value by construction.
The census test pins the whole set against `FieldType.options`.
**H2: what one row assembles.** Measured through the real engine
(sqlite, JSON rows, no mapping) at `055d4b66e9`, with the same result on
the dry run and the commit:
| value sent | dry run | commit | read back |
|:---|:---|:---|:---|
| `{ street, city }` (partial) | ok | ok | `{ street, city }` |
| `{ street: '', city }` (empty part) | ok | ok | `{ street: '', city }`
|
| `{ postalCode: 12345 }` (wrong type) | ok, with warning `invalid_type`
| ok | `{ postalCode: 12345 }` |
| update `{ city }` over `{ street, city }` | | ok | `{ city }` (the
stored value is replaced whole) |
The assembly rule, stated in `applyMappingToRows`' docblock and the docs
row:
- A blank part cell contributes nothing. Blank means empty, whitespace,
or a `nullValues` token.
- The schema would accept `street: ''`, but a blank flat cell leaves its
field unset, and a blank part does the same one level down.
- A string part is trimmed under `trimWhitespace`, as a flat text cell
is. Coercion never reaches inside a compound value, so the trim happens
in the assembly.
- If every part a row maps is blank, the field is left unset.
- Otherwise the assembled object is the field's whole new value. On an
update it replaces the stored one.
**Whole field and part together.** When a mapping writes
`mailing_address` and also `mailing_address.street`, both write the same
key of one row. They are refused as `reason: 'collides'` at all three
doors, naming where the whole field is written. A repeated part target
follows the same last-write rule as a repeated flat target.
**One door.** The dry run and the commit both reach the assembly through
`prepareImportRequest`, so they judge the same assembled row.
`objectstack validate` asks the same spec verdict.
**H5: census of dotted targets** at base `3875ae6773`:
- The repo's one import mapping, `examples/app-showcase`
`showcase_inquiry_feed` (5 entries), has no dotted target.
- No `skills/` or docs page carries a dotted `fieldMapping` target.
- Every dotted `target:` hit in the tree belongs to another schema:
action or form targets, API endpoint `inputMapping` / `outputMapping`,
and the seed loader.
- hotcrm (the card's origin, hotcrm#1836) is NOT MEASURED from here.
## Pins (the pin sweep)
- **Flipped:**
- #20150 pinned `mailing_address.street` as `unknown` in
`import-mapping-target.test.ts`, and as a finding in
`validate-mapping-target-fields.test.ts`, on an object that declares
that address field.
- Both now assert the new meaning: all seven parts answer `part`, and
the card's full address template is green at `validate`.
- The #20150 integration pin keeps refusing `mailing_address.street` on
`task`, which declares no such field. Its comment now says why.
- **New, real engine** (`import-integration.test.ts`, sqlite):
- The customer template's five columns over three rows (full, partial,
all blank).
- The dry run and the commit agree row for row: `ok 3` each.
- Read back: one address object, a two-part address, and an unset field.
- An unknown part and a dotted path on a text field are refused on both
paths with identical bodies and the part list named, and nothing is
written.
- The whole-plus-part collision is refused on both paths.
- The control mapping with no dotted target passes on both.
- **Unit:** the assembly rule in `import-mapping.test.ts` (blank, trim,
`trimWhitespace: false`, every transform, two compound fields kept
apart, no object definition means no part reading). The door's three
refusal texts. The lint rule's three refusals.
## Tests (all through `os-verify-lock.sh`, shared box; tree
`f21f41dbea`)
- `@objectstack/spec`: `vitest run --project local`: 542 files, 15945
passed, 2 todo. `typecheck` (tsc, scripts, test layer): exit 0.
- `@objectstack/rest`: `vitest run --project local`: 199 files, 3569
passed, 1 skipped. `typecheck` (tsc and test layer): exit 0. The `repo`
project is declared to CI.
- `@objectstack/lint`: `vitest run`: 109 files, 4236 passed.
`typecheck`: exit 0.
- `@objectstack/cli` (unit): `test/validate-build-gate-parity.test.ts`:
22 passed. The `integration` layer is declared to CI.
- ESLint over the 10 changed `.ts` files (`--no-inline-config --format
json`): 10 files, 0 errors, 0 warnings.
- The population is the config's `**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}`
minus the build directories, which all 10 files fall under.
- `eslint.config.mjs` enables no type-aware linting (no
`parserOptions.project`), so this diff cannot move the verdict on any
file it does not touch.
- The repo-wide `pnpm lint` is CI's.
### Ablations (one-shot; each through `scripts/ablation-replace.mjs`,
anchor hit 1, blob changed, restored to the HEAD blob with `git diff
HEAD` empty)
- **A.** The spec part arm answers nothing (`import-mapping-target.ts`):
3 red, 18 green in `import-mapping-target.test.ts`. The red tests are
the part arm, the address-by-parts mapping and the collision.
- **B.** The rest assembly is off (`import-mapping.ts`): 5 red, 57
green.
- 4 red are the assembly unit tests; the control stays green.
- The fifth is the integration test. With assembly off, the dry run
still answered `ok 3` while the commit answered `ok 1, errors 2`. That
is the card's own dry-run-versus-commit defect, reproduced.
- **C.** The lint dotted reason is dropped
(`validate-mapping-target-fields.ts`): 2 red, 11 green (the unknown-part
and no-parts refusals).
## Gates
`node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack
--commands` derived 108 families at `f21f41dbea`, and `--ran` reconciles
them: 105 run green, 3 NOT MEASURED, 0 unrun.
- NOT MEASURED, each exit 3 (PREREQUISITE NOT MET):
- `check:skill-examples` needs a `client-react` build. The shared lock
never granted one inside its budget.
- `check:dual-build-cjs-loads` and `check:type-check-debt` read a
whole-repo build that this worktree does not have.
- `check:generated`: all 15 artifacts are up to date after
`gen:api-surface`, `gen:export-origins` and `gen:docs`, which are
committed.
- `check:adr-0087-registration`: green. The changeset declares a
widening only (`Clause-②: yes`), so no disposition marker applies.
- A mergeability probe against `origin/main` `ab820016b3`, from a bare
clone with no regen driver: clean.
## Acceptance notes
- Only `address` is compound for import. A `location` field still
imports whole, as one JSON object. Adding it would need a per-part
number coercion and a required-part check. Carrier: none.
- A JSON-format row can put a non-string value in a part, such as a
number for `postalCode`. That value passes through to the engine's
value-shape check, which admits it with a warning on a warn-first
deployment (measured above). This is the posture every structured value
on the import path already has. Carrier: none.
- On an update, the assembled value replaces the stored address whole
(measured). Merging parts into the stored value would be a new decision.
It is documented in the docs row and the changeset. Carrier: none.
- The collision refusal travels under the existing rule id
`mapping-target-field-unknown` and the existing door code
`INVALID_FIELD`. No new rule id or error code was minted.
- The spec verdict is shared. The door and the lint rule each phrase
their own sentence from it (`head.parts`, `index.parts`), as #20150 left
them.
---
_Generated by [Claude
Code](https://claude.ai/code/session_01QcAS3qiYYZNezaxZxaUdMV)_
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent cfc3bcf commit 443b2f4
15 files changed
Lines changed: 959 additions & 81 deletions
File tree
- .changeset
- content/docs
- data-modeling
- references/data
- packages
- lint/src
- rest/src
- spec
- api-surface
- export-origins
- src/data
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
104 | 104 | | |
105 | 105 | | |
106 | 106 | | |
107 | | - | |
| 107 | + | |
108 | 108 | | |
109 | 109 | | |
110 | 110 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
28 | 28 | | |
29 | 29 | | |
30 | 30 | | |
31 | | - | |
| 31 | + | |
32 | 32 | | |
33 | 33 | | |
34 | 34 | | |
| |||
61 | 61 | | |
62 | 62 | | |
63 | 63 | | |
64 | | - | |
| 64 | + | |
65 | 65 | | |
66 | 66 | | |
67 | 67 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
29 | 29 | | |
30 | 30 | | |
31 | 31 | | |
32 | | - | |
| 32 | + | |
33 | 33 | | |
34 | 34 | | |
35 | 35 | | |
36 | 36 | | |
37 | | - | |
| 37 | + | |
38 | 38 | | |
39 | 39 | | |
40 | 40 | | |
| |||
44 | 44 | | |
45 | 45 | | |
46 | 46 | | |
47 | | - | |
48 | | - | |
| 47 | + | |
| 48 | + | |
49 | 49 | | |
50 | | - | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
| 69 | + | |
| 70 | + | |
| 71 | + | |
| 72 | + | |
| 73 | + | |
| 74 | + | |
| 75 | + | |
| 76 | + | |
| 77 | + | |
| 78 | + | |
| 79 | + | |
| 80 | + | |
| 81 | + | |
| 82 | + | |
| 83 | + | |
| 84 | + | |
| 85 | + | |
| 86 | + | |
| 87 | + | |
| 88 | + | |
| 89 | + | |
| 90 | + | |
| 91 | + | |
| 92 | + | |
| 93 | + | |
| 94 | + | |
| 95 | + | |
| 96 | + | |
| 97 | + | |
| 98 | + | |
| 99 | + | |
| 100 | + | |
| 101 | + | |
| 102 | + | |
| 103 | + | |
| 104 | + | |
| 105 | + | |
| 106 | + | |
| 107 | + | |
| 108 | + | |
| 109 | + | |
| 110 | + | |
| 111 | + | |
| 112 | + | |
| 113 | + | |
| 114 | + | |
| 115 | + | |
| 116 | + | |
| 117 | + | |
| 118 | + | |
| 119 | + | |
| 120 | + | |
| 121 | + | |
| 122 | + | |
| 123 | + | |
| 124 | + | |
| 125 | + | |
| 126 | + | |
| 127 | + | |
51 | 128 | | |
52 | 129 | | |
53 | 130 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
15 | 15 | | |
16 | 16 | | |
17 | 17 | | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
18 | 29 | | |
19 | 30 | | |
20 | 31 | | |
| |||
46 | 57 | | |
47 | 58 | | |
48 | 59 | | |
49 | | - | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
50 | 66 | | |
51 | 67 | | |
52 | 68 | | |
53 | | - | |
| 69 | + | |
| 70 | + | |
| 71 | + | |
| 72 | + | |
| 73 | + | |
54 | 74 | | |
55 | 75 | | |
56 | 76 | | |
| |||
99 | 119 | | |
100 | 120 | | |
101 | 121 | | |
102 | | - | |
103 | | - | |
| 122 | + | |
| 123 | + | |
| 124 | + | |
| 125 | + | |
104 | 126 | | |
105 | 127 | | |
106 | 128 | | |
| |||
128 | 150 | | |
129 | 151 | | |
130 | 152 | | |
131 | | - | |
| 153 | + | |
| 154 | + | |
| 155 | + | |
| 156 | + | |
| 157 | + | |
| 158 | + | |
| 159 | + | |
132 | 160 | | |
| 161 | + | |
133 | 162 | | |
134 | 163 | | |
135 | | - | |
136 | | - | |
137 | | - | |
138 | | - | |
139 | | - | |
| 164 | + | |
| 165 | + | |
| 166 | + | |
| 167 | + | |
| 168 | + | |
| 169 | + | |
| 170 | + | |
| 171 | + | |
| 172 | + | |
| 173 | + | |
| 174 | + | |
| 175 | + | |
| 176 | + | |
| 177 | + | |
| 178 | + | |
| 179 | + | |
| 180 | + | |
| 181 | + | |
140 | 182 | | |
141 | 183 | | |
142 | 184 | | |
143 | | - | |
| 185 | + | |
144 | 186 | | |
145 | 187 | | |
146 | 188 | | |
147 | 189 | | |
148 | 190 | | |
149 | | - | |
150 | | - | |
| 191 | + | |
151 | 192 | | |
152 | 193 | | |
153 | | - | |
| 194 | + | |
| 195 | + | |
| 196 | + | |
154 | 197 | | |
155 | 198 | | |
156 | 199 | | |
157 | 200 | | |
158 | 201 | | |
| 202 | + | |
| 203 | + | |
| 204 | + | |
| 205 | + | |
| 206 | + | |
| 207 | + | |
| 208 | + | |
| 209 | + | |
| 210 | + | |
| 211 | + | |
| 212 | + | |
| 213 | + | |
| 214 | + | |
| 215 | + | |
| 216 | + | |
| 217 | + | |
| 218 | + | |
| 219 | + | |
| 220 | + | |
| 221 | + | |
| 222 | + | |
| 223 | + | |
| 224 | + | |
| 225 | + | |
| 226 | + | |
| 227 | + | |
| 228 | + | |
| 229 | + | |
| 230 | + | |
| 231 | + | |
| 232 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
178 | 178 | | |
179 | 179 | | |
180 | 180 | | |
181 | | - | |
| 181 | + | |
| 182 | + | |
| 183 | + | |
| 184 | + | |
| 185 | + | |
| 186 | + | |
182 | 187 | | |
183 | 188 | | |
184 | 189 | | |
| |||
0 commit comments