Repository navigation
Commit f802213
fix(rest): export lookup cells name the record by the target object's title field (#24980)
Fixes #24944
Clause-②: no
## What changes
`GET /api/v1/data/:object/export` (Display → Export as CSV / XLSX) wrote
a lookup, master_detail, user or tree cell from the lookup field's own
`displayField`. Without one, it took the first scalar key of a fixed
list (`name`, `title`, `label`, `full_name`, …) found on the expanded
record. It never read the target object's `nameField`. A HotCRM contact
declares `nameField: 'full_name'`, has no `name` column and has a job
`title` column, so it exported as its job title.
- `packages/rest/src/export-format.ts`
- New `withReferenceTitleFields(metaMap, readSchema)`. It reads each
reference target's schema once per export (deduplicated by target) and
stamps `titleField` on that field's meta. It is best-effort per target
and never throws: a target whose schema cannot be read keeps the
key-list fallback.
- `ExportCellMeta` is an export-only extension of core's
`ExportFieldMeta` with that one key. `ExportFieldMeta` in
`@objectstack/core` is not touched, and nothing new is exported from the
package index.
- `displayFromRecord` reads, in order: the lookup's own `displayField`;
else the target's `titleField`; else, only when the target resolves no
title, the key list; else the record id.
- `packages/rest/src/data-action-routes.ts`
- The export route's schema read becomes one local `readObjectSchema`
(the same `getMetaItem`, then `getObjectSchema`, order as before). It
serves the export's own object and each target.
- `metaMap = await withReferenceTitleFields(metaMap, readObjectSchema)`
runs before the `$expand` is built.
- No key, query parameter, header, column set or payload shape moves.
Only the text of a lookup cell changes, in csv, xlsx and json alike.
## The title rule: `resolveDisplayField`, not `titleFieldOf`
- `titleFieldOf` (`packages/objectql/src/record-title.ts`) is not
reachable from `@objectstack/rest` without a new runtime dependency.
`@objectstack/objectql` is only a devDependency here.
- `resolveDisplayField` (`@objectstack/spec/data`, `display-name.ts`) is
reachable through the existing dependency. It is ADR-0079's single
resolver, and plugin-audit and plugin-approvals use it to name a related
record. Its rungs are `nameField`, then `displayNameField`, then a
derivation. The first two rungs are exactly `titleFieldOf`'s rule. The
formatter receives the resolved field and holds no rule of its own.
- On every body the protocol serves, the two answer the same. At
registration the registry runs `provisionPrimary(schema, { synthesize:
false })`, which is `resolveDisplayField`, and stamps the result as
`nameField`. Measured through `protocol.getMetaItem` on a real engine (a
throwaway probe, not committed):
| Registered object (no pointer declared) | Served `nameField` | Old
key-list pick |
|---|---|---|
| `id`, `subject` (text), `email` | `subject` | `email` |
| `id`, `label` (select) | `id` | `label` (the raw option code) |
| `title`, `full_name` (text) | `title` | `title` |
| `amount` (number) only | none | none (id floor) |
- They differ only on a served body that carries no pointer but has a
title-eligible field. There `resolveDisplayField` gives ADR-0079's
derivation where `titleFieldOf` gives nothing. None of the measured
registry bodies is in that state.
- Consequence: an object that declares no title is still served with a
designated one. Its lookup cells now follow that designated title. In
the table above, `subject` replaces `email`, and the id replaces a raw
select code.
## Visible changes beyond the reported case
- **Account.** HotCRM's account declares `nameField: 'display_title'`
and has a literal `name` column. Its lookup cells change from `name` to
`display_title`. That follows the declaration (pinned: `Acme` becomes
`Acme (EMEA)`).
- **Field-level security.** The `$expand` sub-read is the engine's own
`find` on the target, so FLS deletes an unreadable title field from the
expanded record. The cell then falls to the record id, not to the key
list. The card's mechanism assumption 3 expected the fallback "as it
does today". Today's fallback for an unreadable `full_name` is the job
title, which is the defect itself. The binding ruling keeps the key list
only for an object that declares neither.
- The lookup field's own `displayField` still wins (pinned as a
control).
## Evidence
All readings are on `packages/rest`. They are taken at `0fb2a82b06` (the
fix) and `6dabdfbeab` (the unit pins and the changeset), on top of base
`693352e4c`.
- **Red on `main`.** `src/export-lookup-title-field.test.ts` run against
the unmodified route: `Tests 5 failed | 2 passed (7)`. The contact cell
read `[ '1', 'VP of Engineering' ]` where `[ '1', 'John Smith' ]` was
expected. The two controls (the lookup's own `displayField`, and a
target served with no pointer) were green.
- **Green with the fix.** The same file, now 10 tests: `Tests 10 passed
(10)`.
- **Ablation 1, the title-field read.** `withReferenceTitleFields`'s
`resolveDisplayField(schema as never)` was replaced by `undefined`
through `scripts/ablation-replace.mjs` (anchor 1 → 0, blob `6fd4e383` →
`dbe37ef0`). Result: `Tests 5 failed | 2 passed (7)`, with the first pin
red. Restored to `6fd4e383032f` == HEAD, and `git diff HEAD` was empty.
- **Ablation 2, the key list held back when a title exists.** `} else {`
was replaced by `} {` (blob `6fd4e383` → `f6f403cc`). Result: `Tests 1
failed | 6 passed (7)`, the FLS pin. Restored hash-equal to HEAD.
- The subject is this package's own `src/`, reached by relative import,
so the ablations needed no `dist/` rebuild.
## Gates
All readings below are at the final head `321ea1f2be`. That is
`6dabdfbeab` with `origin/main` `6440cf1633` merged in; the merged
commit touches only plugin-security and its changeset.
- `pnpm lint` (`eslint . --no-inline-config`): exit 0, no findings
printed.
- `node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack`: 66 derived commands, plus the 10 families
under "Declared WIDE population". All 76 were run with the exit code
captured before any pipe, and all 76 exited 0.
- `--ran` reconciliation: `✓ dispatch-gates --ran: 66 derived famil(ies)
accounted for — 66 run, 0 NOT-MEASURED (a DERIVED zero — all 66 recorded
an exit code and none of them is 3).`
- `pnpm --filter @objectstack/rest exec vitest run --project local
--maxWorkers=2`: `Test Files 306 passed (306)`, `Tests 6154 passed | 326
skipped (6480)`.
- `pnpm --filter @objectstack/rest typecheck`: exit 0.
## Acceptance notes
- **Export, then re-import, of a lookup column.** The import's reference
resolver (`packages/core/src/utils/import-runner.ts`, `resolveRef`)
matches a cell against `id`, the lookup's `displayField`, then `name`,
`title`, `label`, `full_name`, `email` and `username`. It never reads
the target's title field. Measured through `POST
/api/v1/data/xt_case/import` on the fixture of this PR:
- `John Smith` (a contact named by its formula `full_name`) answers
`reference_not_found`. So do `Wren` (a stored `displayNameField` that is
not on the list) and `Acme (EMEA)` (a formula `display_title`).
- The cells `main` wrote all resolved: `VP of Engineering` and `Chief
Procurement Officer` by `title`, and `Acme` by `name`.
- So a file this export writes no longer re-imports those lookup
columns. The import side is in `packages/core`, which is held off for
this card. It is reported to the dispatching seat as a finding, and not
fixed here.
- No other import or export path was changed.
---
_Generated by [Claude
Code](https://claude.ai/code/session_01LmjU2x1faLZ39DVTFyVca6)_
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent 0603bfa commit f802213
4 files changed
Lines changed: 426 additions & 23 deletions
File tree
- .changeset
- packages/rest/src
| 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 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
64 | 64 | | |
65 | 65 | | |
66 | 66 | | |
67 | | - | |
| 67 | + | |
| 68 | + | |
68 | 69 | | |
69 | 70 | | |
70 | 71 | | |
| |||
1086 | 1087 | | |
1087 | 1088 | | |
1088 | 1089 | | |
1089 | | - | |
| 1090 | + | |
1090 | 1091 | | |
1091 | 1092 | | |
1092 | 1093 | | |
1093 | | - | |
1094 | | - | |
1095 | | - | |
1096 | | - | |
1097 | | - | |
1098 | | - | |
1099 | | - | |
1100 | | - | |
1101 | | - | |
| 1094 | + | |
| 1095 | + | |
| 1096 | + | |
| 1097 | + | |
| 1098 | + | |
| 1099 | + | |
| 1100 | + | |
| 1101 | + | |
| 1102 | + | |
| 1103 | + | |
1102 | 1104 | | |
1103 | 1105 | | |
1104 | | - | |
| 1106 | + | |
1105 | 1107 | | |
1106 | 1108 | | |
1107 | 1109 | | |
1108 | | - | |
| 1110 | + | |
1109 | 1111 | | |
| 1112 | + | |
| 1113 | + | |
| 1114 | + | |
| 1115 | + | |
1110 | 1116 | | |
1111 | 1117 | | |
1112 | 1118 | | |
| |||
1134 | 1140 | | |
1135 | 1141 | | |
1136 | 1142 | | |
| 1143 | + | |
| 1144 | + | |
| 1145 | + | |
| 1146 | + | |
| 1147 | + | |
| 1148 | + | |
| 1149 | + | |
1137 | 1150 | | |
1138 | 1151 | | |
1139 | 1152 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
31 | 31 | | |
32 | 32 | | |
33 | 33 | | |
| 34 | + | |
34 | 35 | | |
35 | 36 | | |
36 | 37 | | |
37 | 38 | | |
38 | 39 | | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
39 | 57 | | |
40 | 58 | | |
41 | 59 | | |
| |||
102 | 120 | | |
103 | 121 | | |
104 | 122 | | |
105 | | - | |
| 123 | + | |
| 124 | + | |
| 125 | + | |
106 | 126 | | |
107 | 127 | | |
108 | 128 | | |
| |||
121 | 141 | | |
122 | 142 | | |
123 | 143 | | |
| 144 | + | |
| 145 | + | |
| 146 | + | |
| 147 | + | |
| 148 | + | |
| 149 | + | |
| 150 | + | |
| 151 | + | |
| 152 | + | |
| 153 | + | |
| 154 | + | |
| 155 | + | |
| 156 | + | |
| 157 | + | |
| 158 | + | |
| 159 | + | |
| 160 | + | |
| 161 | + | |
| 162 | + | |
| 163 | + | |
| 164 | + | |
| 165 | + | |
| 166 | + | |
| 167 | + | |
| 168 | + | |
| 169 | + | |
| 170 | + | |
| 171 | + | |
| 172 | + | |
| 173 | + | |
| 174 | + | |
| 175 | + | |
| 176 | + | |
| 177 | + | |
| 178 | + | |
| 179 | + | |
| 180 | + | |
| 181 | + | |
| 182 | + | |
| 183 | + | |
| 184 | + | |
| 185 | + | |
| 186 | + | |
| 187 | + | |
124 | 188 | | |
125 | 189 | | |
126 | 190 | | |
| |||
333 | 397 | | |
334 | 398 | | |
335 | 399 | | |
336 | | - | |
| 400 | + | |
| 401 | + | |
| 402 | + | |
| 403 | + | |
| 404 | + | |
| 405 | + | |
| 406 | + | |
| 407 | + | |
| 408 | + | |
| 409 | + | |
| 410 | + | |
| 411 | + | |
| 412 | + | |
| 413 | + | |
| 414 | + | |
337 | 415 | | |
338 | | - | |
339 | | - | |
| 416 | + | |
| 417 | + | |
340 | 418 | | |
| 419 | + | |
| 420 | + | |
| 421 | + | |
| 422 | + | |
| 423 | + | |
341 | 424 | | |
342 | 425 | | |
343 | 426 | | |
344 | 427 | | |
345 | 428 | | |
346 | | - | |
| 429 | + | |
347 | 430 | | |
348 | | - | |
| 431 | + | |
349 | 432 | | |
350 | 433 | | |
351 | 434 | | |
| |||
359 | 442 | | |
360 | 443 | | |
361 | 444 | | |
362 | | - | |
| 445 | + | |
363 | 446 | | |
364 | 447 | | |
365 | 448 | | |
| |||
377 | 460 | | |
378 | 461 | | |
379 | 462 | | |
380 | | - | |
| 463 | + | |
381 | 464 | | |
382 | 465 | | |
383 | 466 | | |
| |||
390 | 473 | | |
391 | 474 | | |
392 | 475 | | |
393 | | - | |
| 476 | + | |
394 | 477 | | |
395 | 478 | | |
396 | 479 | | |
| |||
403 | 486 | | |
404 | 487 | | |
405 | 488 | | |
406 | | - | |
| 489 | + | |
407 | 490 | | |
408 | 491 | | |
409 | 492 | | |
| |||
0 commit comments