Skip to content

Commit 74801d1

Browse files
fix(studio): stop offering the dead field.externalId toggle, and keep a stored value through every save (#27315)
<!-- claude-projects-attribution: {"github_login":"os-support-ai"} --> _Requested by **Yibo Wang** · [project thread](https://claude.ai/code/project/chan_01BVUyyPie8gHwoPM18iABwB?thread=cmsg_01BVUyyPie8gHwoPM18iABwB3FHBKJzKkbDiW2okoG1HcZ)_ Fixes #26674 Claim: 6471969865 Clause-②: no Metadata-protocol: none Studio stops offering the key; the protocol keeps parsing it. This is not a retirement: no tombstone, no narrowing, no schema, type or export change. `FieldSchema` accepts `externalId` exactly as before, and a field that already carries `externalId: true` keeps it through every Studio save. ## 维护者速读 - **改了什么:** Studio 的字段元数据表单、字段设计器抽屉、字段检查器三处不再显示「External ID」开关和「用于 upsert」的帮助文字;已经存了 `externalId: true` 的字段,在 Studio 里编辑保存后原样保留。 - **为什么改:** `field.externalId` 已判为 dead,任何 upsert、导入、种子都不按它匹配。开关让人以为重新导入会按它更新而不是新增,结果静默产生重复记录,`os lint` 还会对表单自己给出的值报警告。 - **风险与回滚:** 低。协议照旧解析该键,只删 UI 控件和只被它用到的文案;设计器更新路径改为靠展开保留旧值,三个保存面各有往返测试钉住。回滚 = revert 本 PR。 - **先看:** `ui/packages/plugin-designer/src/FieldDesigner.tsx`(更新路径不再写 `externalId`)、`ui/packages/app-shell/src/views/metadata-admin/inspectors/ObjectFieldInspector.tsx`(删复选框)、`packages/spec/src/data/field.form.ts`(删表单行)。 - **两分钟验证:** `cd ui && pnpm exec vitest run packages/plugin-designer/src/MetadataFieldsPage.externalIdRoundTrip-26674.test.tsx`,预期 2 个用例全绿:编辑保存后 PUT 里两个字段的 `externalId` 仍是 `true`。 - **需要你做的:** 无,席位落地。 ## What changes Measured first on `origin/main` `2302b92b`: three Studio surfaces offered the toggle. | Surface | Before | After | |---|---|---| | Spec field metadata form (`packages/spec/src/data/field.form.ts`, Advanced section) | row `externalId`, help text "Mark as external ID for upsert operations", repeated in all four `*.metadata-forms.generated.ts` bundles (en "External Id" / zh-CN "外部 ID" / ja-JP / es-ES) | row removed; bundles regenerated with `node scripts/check-i18n-bundles.mjs --write --filter=platform-objects` (16 lines removed, nothing else moved) | | Field Designer drawer (`ui/packages/plugin-designer/src/FieldDesigner.tsx`, Advanced section; used by `MetadataFieldsPage.tsx`) | `externalId` boolean control; update path wrote `externalId: data.externalId === true` | control and its initial value removed; the update path no longer names the key, so `...editingField` carries a stored value through; a created field no longer carries `externalId: false` | | Field inspector (`ui/packages/app-shell/.../inspectors/ObjectFieldInspector.tsx`, Advanced section) | `External ID` checkbox | removed; `patchDef` spreads the whole stored definition, so the key survives every edit | - **i18n strings only the toggle used:** `appDesigner.fieldDesigner.externalId` removed from all ten `ui/packages/i18n/src/locales/*.ts` packs and from the plugin-designer fallback table (`useDesignerTranslation.ts`); `designer.field.externalId` removed from the Studio namespace (`en`, `zh`). - **`MetadataFieldsPage.tsx` is unchanged.** Its `toDesignerField` / `fromDesignerField` pair already carries `externalId` both ways; with the drawer now preserving it, the round-trip holds, and a test pins it end to end. - **Form ↔ Zod reconciliation:** `externalId` is recorded as a deliberate root omission of the `field` form, with its reason (graded `dead`, the schema still parses it, the seed dataset's `externalId` and an import mapping's `upsertKey` are the real match keys). - **Changesets (patch, one per touched published package):** `@objectstack/spec`, `@objectstack/platform-objects`, `@object-ui/plugin-designer`, `@object-ui/i18n`. `@object-ui/app-shell` is private, so it gets none. - **Liveness evidence** (`packages/spec/liveness/field.json`, row `externalId`, claim amendment 6474597348): the evidence said Studio's inspector and designer edit the key, which this PR makes false. It now says the protocol still parses the key, no Studio surface offers it (form row, designer drawer, inspector), and the `ui/` mentions only carry a stored value through a save. Prose only: the grade stays `dead`, `verifiedAt` and `authorHint` are unchanged. - **Not governed:** no `skills/**`, `content/docs/**`, `.claude/**` or other register path is touched. ## Tests - `packages/spec/src/data/field-form-external-id-not-offered.test.ts` (new, 4 cases): the field form declares no `externalId` row at any depth (with a positive control that the walk reaches `storage`); no help text still calls a field flag an upsert key; `FieldSchema` still parses an authored `externalId: true`; the liveness row `field.externalId` still reads `dead`. - `ui/packages/plugin-designer/src/__tests__/FieldDesigner.externalIdNotOffered-26674.test.tsx` (new, 4 cases): no `externalId` control in the create or edit drawer (each paired with the `trackHistory` sibling as positive control); a field with `externalId: true` keeps it through the drawer's edit-and-save; a created field carries no `externalId` key. - `ui/packages/plugin-designer/src/MetadataFieldsPage.externalIdRoundTrip-26674.test.tsx` (new, 2 cases): the REAL `FieldDesigner` over a real `MetadataClient` and a fetch double; after an edit-and-save the PUT keeps `externalId: true` on the edited field and on an untouched one, a field without the flag gains none, and every PUT field parses through `FieldSchema`. - `ui/packages/app-shell/src/views/metadata-admin/inspectors/ObjectFieldInspector.externalIdNotOffered-26674.test.tsx` (new, 3 cases): no `External ID` control (with its Advanced siblings rendering), a stored value does not resurrect it, and a label edit keeps `externalId: true` in the saved fields map. - Moved pins: `metadata-form-zod-reconciliation.test.ts` gains the `field` root omission row; `object-lifecycle-panel-echo-decisions.test.ts` translated-leaf positive control 695 → 694 (one row label fewer, per locale); `ObjectFieldInspector.retiredIndexed.test.tsx` drops `External ID` from its list of Advanced siblings. ## Acceptance notes - `DesignerFieldDefinition.externalId` (`@object-ui/types`), its Zod mirror and `MetadataService`'s field summary are unchanged on purpose: they carry the stored value, which is what keeps it from being stripped. - `content/docs/**` and `skills/**` already describe the field flag as "Not read" and teach the seed dataset's `externalId`, so nothing there became false. ## Verification All readings at head `b357ce35` unless stated; local runs under `scripts/pm/os-verify-lock.sh`. Round r1 (head `0b08d306`, the liveness evidence prose only): `pnpm --filter @objectstack/spec check:liveness` exit 0 (80 dead, 1219 classified); `pnpm --filter @objectstack/spec test` 749 passed + 1 skipped of 750 files, 22379 passed, 0 failed. <details> <summary>Package tests and type-checks</summary> - `pnpm --filter @objectstack/spec test` at `5570dc7a`: 749/750 files, 22380 passed, 1 failed (`metadata-form-zod-reconciliation`, the expected root-omission ripple). Fixed in `b357ce35`; that file, the new spec test and `metadata-form-declared-rows.pin.test.ts` then 3/3 files, 91/91 tests. - `pnpm --filter @objectstack/spec typecheck`: exit 0. - `pnpm --filter @objectstack/platform-objects test` at `5570dc7a`: 95/96 files, 1360 passed, 1 failed (the 695 leaf-count pin). Fixed in `b357ce35`; that file 35/35. - `pnpm --filter @objectstack/platform-objects typecheck`: exit 0. - `cd ui && pnpm exec vitest run packages/plugin-designer/ packages/i18n/`: 139/139 files, 1833 passed, 13 skipped. - app-shell, narrowed to the 40 test files that import `ObjectFieldInspector` (the full app-shell suite is CI's Test UI): 40/40 files, 459/459 tests. - `pnpm exec turbo run type-check --filter=@object-ui/plugin-designer --filter=@object-ui/i18n --filter=@object-ui/app-shell`: 66/66 tasks, each of the three type-checks executed (cache miss). - `pnpm --dir ui run check:designer-field-key-parity`, `check:i18n-keys`, `check:i18n-drift`: exit 0 each (drift: 1 key removed, 0 values changed). - `node scripts/check-i18n-bundles.mjs` (`pnpm check:i18n`): OK, 10 packages in sync. </details> <details> <summary>Ablation (the update-path change)</summary> `node scripts/ablation-replace.mjs` put `externalId: data.externalId === true` back into `FieldDesigner.handleFormSuccess`'s update path (anchor 1 → 0, blob `a0985e17` → `6a7c1f3e`). The two round-trip cases went red, exactly those two: `FieldDesigner … keeps externalId: true through the update path` and `MetadataFieldsPage … keeps externalId: true on the edited field and on an untouched one` (`expected false to be true`); 4 other cases stayed green. Restored: blob equals HEAD, `git diff HEAD` empty. </details> <details> <summary>Derived gates</summary> `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`: 85 families. Reconciliation with `--ran`: 85 derived, 85 run, 0 NOT-MEASURED, 0 UNRUN; every recorded exit is 0. On the first pass nine families ran while a turbo build was rewriting `packages/spec/dist` (exit 3 / timeout) and were re-run on a settled tree, exit 0 each. Then all 85 re-run once more, serially, on the final head `b357ce35` with nothing else running: 85/85 exit 0; `--ran` again 85 derived, 85 run, 0 NOT-MEASURED, 0 UNRUN (`dispatch-gates --ran` exit 0). </details> --- _Generated by [Claude Code](https://claude.ai/code/session_01WGufzg4R646jZ5Ky7hqq9L)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 10f27b6 commit 74801d1

32 files changed

Lines changed: 461 additions & 44 deletions
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
'@object-ui/i18n': patch
3+
---
4+
5+
fix(i18n): remove the External ID strings only the retired Studio switch used
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
The Field Designer and the field inspector no longer offer an External ID switch (`field.externalId` is graded `dead`), so `appDesigner.fieldDesigner.externalId` is removed from all ten locales and `designer.field.externalId` from the Studio namespace (`en`, `zh`).
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
'@objectstack/platform-objects': patch
3+
---
4+
5+
fix(platform-objects): drop the field form's `externalId` labels from the metadata-forms translations
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
The field metadata form no longer offers `externalId` (the key is graded `dead`: nothing matches on it), so its label and its "Mark as external ID for upsert operations" help text are regenerated out of the `metadata-forms` bundles for every locale (`en`, `zh-CN`, `ja-JP`, `es-ES`).
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
'@object-ui/plugin-designer': patch
3+
---
4+
5+
fix(plugin-designer): the Field Designer drawer no longer offers an External ID switch, and keeps a stored `externalId`
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
`field.externalId` is graded `dead`: no upsert, import or seed matches on it, so the drawer's Advanced section stops offering it. The key is not retired, so a stored value must survive: the drawer's update path used to write `externalId: data.externalId === true`, and now leaves the key to the field's own spread, so a field carrying `externalId: true` keeps it through an edit-and-save on the Metadata Fields page. A newly created field no longer carries `externalId: false`. The `appDesigner.fieldDesigner.externalId` fallback string is removed.
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
'@objectstack/spec': patch
3+
---
4+
5+
fix(spec): the field metadata form no longer offers `externalId`
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
`field.externalId` is graded `dead`: no upsert, import or seed matches on it. The field form's Advanced section still offered it with the help text "Mark as external ID for upsert operations", so an author who switched it on for an import key got duplicates on re-import and an `os lint` warning on the flag the form had offered. The row is removed from `fieldForm`. The key itself is unchanged: `FieldSchema` still parses `externalId`, and a field that carries it stays valid metadata. To match records on a field, name it in a seed dataset's `externalId` or in an import mapping's `upsertKey`.

‎packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts‎

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -813,10 +813,6 @@ export const enMetadataForms: NonNullable<TranslationData['metadataForms']> = {
813813
label: "Filter",
814814
helpText: "Only child rows matching this predicate are aggregated (e.g. status == received)"
815815
},
816-
externalId: {
817-
label: "External Id",
818-
helpText: "Mark as external ID for upsert operations"
819-
},
820816
storage: {
821817
label: "Storage",
822818
helpText: "Physical storage constraints (ADR-0113): the DDL the write contract deliberately does not imply. Unset: none requested."

‎packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts‎

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -813,10 +813,6 @@ export const esESMetadataForms: NonNullable<TranslationData['metadataForms']> =
813813
label: "Filtro",
814814
helpText: "Solo se agregan las filas hijas que coinciden con este predicado (p. ej. status == received)"
815815
},
816-
externalId: {
817-
label: "ID externo",
818-
helpText: "Marca como ID externo para operaciones upsert"
819-
},
820816
storage: {
821817
label: "Almacenamiento",
822818
helpText: "Restricciones físicas de almacenamiento (ADR-0113): el DDL que el contrato de escritura deliberadamente no implica. Sin definir: no se solicita ninguna."

‎packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts‎

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -813,10 +813,6 @@ export const jaJPMetadataForms: NonNullable<TranslationData['metadataForms']> =
813813
label: "フィルター",
814814
helpText: "この述語に一致する子行のみが集計されます(例: status == received)"
815815
},
816-
externalId: {
817-
label: "外部 ID",
818-
helpText: "upsert 操作用の外部 ID としてマーク"
819-
},
820816
storage: {
821817
label: "ストレージ制約",
822818
helpText: "物理ストレージの制約(ADR-0113)。書き込み契約があえて含めていない DDL を指定します。未設定の場合、制約は要求されません。"

‎packages/platform-objects/src/apps/translations/object-lifecycle-panel-echo-decisions.test.ts‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1238,7 +1238,9 @@ describe('#19403 round 10 — the verdicts, on the live bundles', () => {
12381238
// 695 since #27069: the object form's `relatedLists` row and the
12391239
// `fieldGroups` repeater's `relatedList` sub-row — two row labels,
12401240
// authored in all three locales.
1241-
expect(translated.length, `${locale} positive control`).toBe(695);
1241+
// 694 since #26674: the field form no longer offers `externalId` (graded
1242+
// `dead`) — one row label fewer, in all three locales.
1243+
expect(translated.length, `${locale} positive control`).toBe(694);
12421244
}
12431245
// ⭐ DARK — the blindness, executable. On a synthetic two-locale catalog the
12441246
// all-three predicate returns 0 while the per-locale one returns 1, so the

‎packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts‎

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -813,10 +813,6 @@ export const zhCNMetadataForms: NonNullable<TranslationData['metadataForms']> =
813813
label: "筛选",
814814
helpText: "仅聚合匹配该判定式的子记录(例如 status == received)"
815815
},
816-
externalId: {
817-
label: "外部 ID",
818-
helpText: "标记为外部 ID 用于 upsert 操作"
819-
},
820816
storage: {
821817
label: "存储约束",
822818
helpText: "物理存储约束(ADR-0113):写入契约刻意不隐含的 DDL。未设置:不要求任何约束。"

‎packages/spec/liveness/field.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -382,7 +382,7 @@
382382
"status": "dead",
383383
"verifiedAt": "2026-10-27",
384384
"evidenceScope": "in-repo",
385-
"evidence": "no reader: at 1c632311, outside packages/spec, nothing under packages/** or ui/** reads a field's `externalId`. The `ui/` mentions only carry it: the Studio field inspector edits it (ui/packages/app-shell/src/views/metadata-admin/inspectors/ObjectFieldInspector.tsx#ObjectFieldInspector), ui/packages/app-shell/src/services/MetadataService.ts copies it into a field summary, and the designer's field form edits and round-trips it (ui/packages/plugin-designer/src/FieldDesigner.tsx, ui/packages/plugin-designer/src/MetadataFieldsPage.tsx). None is a preview: no `registerMetadataPreview('field', …)` exists, and no registered preview (ui/packages/app-shell/src/views/metadata-admin/previews/, `object` included) reads `externalId`. Every upsert matches on something else: a seed dataset's `externalId` (packages/metadata-protocol/src/seed-loader.ts, `name` by default) or an import mapping's `upsertKey` (packages/rest/src/import-prepare.ts)",
385+
"evidence": "no reader: at 1c632311, outside packages/spec, nothing under packages/** or ui/** reads a field's `externalId`. The protocol still parses it (`FieldSchema` keeps the key; not a retirement), and no Studio surface offers it (#26674): the field metadata form has no row for it (packages/spec/src/data/field.form.ts, a recorded root omission), and neither the designer's field drawer (ui/packages/plugin-designer/src/FieldDesigner.tsx) nor the field inspector (ui/packages/app-shell/src/views/metadata-admin/inspectors/ObjectFieldInspector.tsx#ObjectFieldInspector) has a control for it. The `ui/` mentions only carry a stored value through a save: those two surfaces, ui/packages/plugin-designer/src/MetadataFieldsPage.tsx, and ui/packages/app-shell/src/services/MetadataService.ts, which copies it into a field summary. None is a preview: no `registerMetadataPreview('field', …)` exists, and no registered preview (ui/packages/app-shell/src/views/metadata-admin/previews/, `object` included) reads `externalId`. Every upsert matches on something else: a seed dataset's `externalId` (packages/metadata-protocol/src/seed-loader.ts, `name` by default) or an import mapping's `upsertKey` (packages/rest/src/import-prepare.ts)",
386386
"note": "RE-GRADED live -> dead 2026-10-27 (#25753, follow-up F23 of #25712). The `live` grade rested on objectui console `metadataConverters.ts` (:125, commit not pinned), which objectui dc14bcb deleted for having zero importers; the re-measure above found no other reader. A boolean `default(false)`, so the author lint warns only on an authored `true`. Owed enforce-or-remove (ADR-0049).",
387387
"authorHint": "Remove it: no upsert reads a field's `externalId`. To match seed records on this field, name it in the seed dataset's `externalId`; for an import, in the mapping's `upsertKey`."
388388
},

0 commit comments

Comments
 (0)