Skip to content

Commit eeb3117

Browse files
feat(spec)!: key an object extension's fields by the field map's own key grammar (#29774)
<!-- claude-projects-attribution: {"github_login":"os-support-ai"} --> _Requested by **Yibo Wang** · [project thread](https://claude.ai/code/project/chan_01BVUyyPie8gHwoPM18iABwB?thread=cmsg_01BVUyyPie8gHwoPM18iABwB9MJTq8giFNFawGE7vvMCTA)_ Fixes #29523 Clause-②: no (narrowing) Metadata-protocol: narrowed Skills-docs: updated here — `content/docs/data-modeling/object-extensions.mdx` ("Naming the fields you add") gains the key grammar. `skills/objectstack-data/SKILL.md` and its evals teach extension fields only as `region__c`, so every taught line stays true and no skill line changes. ## 维护者速读 - 改了什么:对象扩展的 `fields`(扩展新增的字段)的键改用与对象 `fields`、扩展 `fieldPatches` 同一个键 schema(小写 snake_case),`Region Code`、`region-code` 这类对象自己的字段表不可能容纳的键在解析时就被拒,报一个带示例的 `invalid_key`。 - 为什么改:以前任何字符串都能过 schema。实测 main 上 `defineStack`、`os validate` 都放行;在 `.custom` 包里 `Region Code__c` 被保存门保存并发布、出现在每个对象读取里、启动重读照样注册,还按这个名字建列存了值。 - 风险与回滚:收窄(BREAKING,spec minor,ADR-0087 语义迁移条目已登记,`os migrate meta --stored` 会列出存量坏键行)。仓内 examples/skills/docs/模板无一处写过这种键;已存储的带坏键的扩展行升级后整行不再注册(其 `fieldPatches` 一并),再保存被 422 拒,直到改名。回滚即 revert 本 PR。 - 先看:`packages/spec/src/data/object.zod.ts`(`ObjectExtensionSchema.fields` 的键 schema 与 `invalid_key` 文案)、`packages/spec/src/conversions/refused-shapes.ts`(新检测器)、`packages/spec/src/migrations/entries/semantic/18.object-extension-field-key-grammar-refused.ts`。 - 两分钟验证:`pnpm --filter @objectstack/spec exec vitest run src/data/field-customization.test.ts src/conversions/refused-shapes.test.ts`,预期 2 个文件全绿(192 条)。 - 需要你做的:无,席位落地。 ## What changes - `packages/spec/src/data/object.zod.ts`: `ObjectExtensionSchema.fields` is keyed by `FieldNameKeySchema`, the one key schema `ObjectSchema.fields` and `ObjectExtensionSchema.fieldPatches` already read (not exported; its doc comment now lists all three readers). The record's error map answers `invalid_key` with a prescription: the key is the name of a field the extension adds, lowercase snake_case such as `region_code` (`region_code__c` in a customization package), never its label. The issue sits at `fields.KEY`, as the `fieldPatches` one sits at `fieldPatches.KEY`. The `fields` describe names the grammar. `__proto__` stays refused on the raw input by `refuseRecordProtoKey`, unchanged. - `packages/spec/src/conversions/refused-shapes.ts`: one detector, `object-extension-field-key-grammar-refused`, appended after the `fieldPatches` one. Its predicate is the refusing door's own: `ObjectExtensionSchema.safeParse`, keeping each `invalid_key` issue at `fields.KEY` (the `fieldPatches` reader and this one now share one helper, `refusedExtensionRecordKeys(extension, slot)`). It lists, never rewrites, names the target, shows only the key and the field's `type` in `from`, and closes each reason with the parse's own message. - `packages/spec/src/migrations/entries/semantic/18.object-extension-field-key-grammar-refused.ts`: the ADR-0087 semantic entry (refused, with a prescription; no conversion, because the key is the name a field is stored and read under, and a respelling names a different column or lands on a field the target already declares). - `.changeset/29523-object-extension-field-key-grammar.md`: spec `minor`, BREAKING, `adr-0087: registered object-extension-field-key-grammar-refused`, with the FROM → TO line. - `content/docs/data-modeling/object-extensions.mdx`: one paragraph in "Naming the fields you add". `content/docs/references/data/object.mdx`: regenerated (the `fields` describe moved). - No public export is added or removed. No existing test or fixture needed an edit: every extension `fields` key in `examples/**`, `skills/**`, `content/docs/**`, templates and test fixtures is already snake_case. ## Why — the measurement that chose the declaration Measured on `origin/main` `1c1a5d40` (spec built from main's `object.zod.ts`), then on this branch: | Door | `main` | this branch | |:--|:--|:--| | `ObjectExtensionSchema` / `defineObjectExtension` (`fields: { 'Region Code': … }`) | parses | one `invalid_key` at `fields.Region Code` | | `defineStack({ objectExtensions })`, target outside the stack | accepted | refused, `objectExtensions.0.fields.Region Code` | | `defineStack`, target inside the stack | accepted (the merged object is not re-parsed) | refused | | `os validate` (CLI on a scratch `defineStack` config) | no issue on the extension (only an unrelated `security-owd-unset` on the scratch object) | exit 1, both extension keys named | | `object_extension` save + publish door, `.custom` pair, key `Region Code__c` | stored and published; served on the by-name, list, layered and registry reads; a record insert stored `EMEA` under `Region Code__c`; a boot re-read (`loadMetaFromDb`) registered it again with 0 errors | save refused 422 `INVALID_METADATA`, issue `invalid_key` at `fields.Region Code__c`; no row stored; insert refused `INVALID_FIELD` | | A row stored before the upgrade, re-read at boot | n/a | `loadMetaFromDb` counts 1 error, the extension is not registered (no extender), and its re-save is refused 422 | So every authoring door accepted such a key end to end, which makes this `Clause-②: no (narrowing)` · `Metadata-protocol: narrowed`, BREAKING, with the semantic entry and the `REFUSED_STORED_SHAPES` detector the card asks for (the stored pass reaches `object_extension` rows since #29589). ## Tests - `packages/spec/src/data/field-customization.test.ts` gains the describe "extension `fields` keys — the field map's own key grammar" (8 cases): `Region Code`, `RegionCode`, `region-code`, `2nd_phone`, `Region Code__c` each refused with exactly one `invalid_key` at `fields.KEY` (message names the grammar and an example, no tracker id); CONTROL `region_code` and `region_code__c` parse; CONTROL both authored examples of the object-extension guide parse whole; and an agreement test that the extension `fields` record refuses at the key exactly the keys `ObjectSchema.fields` refuses at the key, over an 11-key sample that holds keys on both sides (guarded against vacuity). - `packages/spec/src/conversions/refused-shapes.test.ts`: the registered id list gains the new id last; one TODO per refused key at `objectExtensions[0].fields.KEY`, the row unchanged; the listed keys equal the parse's `invalid_key` issues and each reason ends with the parse's message; snake_case keys (`__c` included) are not listed and parse; and a row with both a bad `fields` key and a bad `fieldPatches` key is listed once by each detector, never crossed. ## Acceptance notes - Not changed here: the manifest fold (`ObjectQL.registerApp`, the `objectExtensions` branch in `packages/objectql/src/engine.ts`) copies `ext.fields` as given and parses nothing; measured on this branch, `registerApp` with `fields: { 'Region Code': … }` still registers the field and stores a value. Every authoring path that produces a manifest (`defineStack`, `os validate`, `os build`, the save door) now refuses the key first, so only an artifact built before the upgrade reaches it. That is the fold's trust model for every collection, not this card's; noted, not filed. - Not changed here, same family: `ObjectSchema.fields` refuses `constructor` and `prototype` by record refines that sit outside the key schema, so `ObjectExtensionSchema.fields: { constructor: … }` still parses (as `fieldPatches: { constructor: … }` does, noted on #29513). Noted, not filed. ## Verification All readings on objectstack-ai/objectstack, head `bd50ef25` (merge of `origin/main` `87956dcc`; the delta against main is the eight files listed above). <details><summary>Door probes (scratch, not committed)</summary> A throwaway copy of the `packages/runtime/src/domains/meta-object-extension-item.test.ts` rig (better-sqlite3 `:memory:`, real `ObjectQL`, real protocol), run once against a spec dist built from main's `object.zod.ts` and once against this branch's; and `os validate` on a scratch `defineStack` config in `packages/cli`. Readings are the table above. The probe files were deleted before commit. </details> <details><summary>Ablation: the new tests can fail</summary> `node scripts/ablation-replace.mjs` replaced `z.record(FieldNameKeySchema, FieldSchema, {` with `z.record(z.string(), FieldSchema, {` in `object.zod.ts` (anchor 1 → 0, blob `06e35bf5` → `09e91a78`) and ran `src/data/field-customization.test.ts src/conversions/refused-shapes.test.ts`: 9 failed | 183 passed (192). The five refusal cases, the agreement test and the three new listing tests went red; every control stayed green. Restored: blob == HEAD, `git diff HEAD` empty. The spec suite imports `./object.zod` from source, so no build sits between the mutation and the run. Taken on `61876fc4`; the tested lines did not change in the merge. </details> <details><summary>Spec and consumer packages (downstream of the contract)</summary> Under `scripts/pm/os-verify-lock.sh`. On the final head `bd50ef25`, after a full `turbo run build --concurrency=2` (117/117 tasks): - `@objectstack/spec` `check:generated`: exit 0. `test`: 803/803 files, 23807 passed | 1 todo. - `@objectstack/organizations` `object-extension-managed-target.wall.test.ts`: 1/1, 5 tests. `@objectstack/plugin-auth` `identity-objects-plugin.test.ts`: 1/1, 15. `@objectstack/plugin-security` `upgrade-impact-report.wiring.test.ts`: 1/1, 5. - `@objectstack/downstream-contract` test: 3/3, 31. `@objectstack/example-showcase` test: 45/45, 541. On `61876fc4` (before the main merge; spec built from this branch): - `@objectstack/spec` `test`: 803/803 files, 23798 passed | 1 todo. `typecheck`: exit 0. `test:repo`: 89/89, 1522. - `@objectstack/metadata-core` (full): 23/23, 904. `@objectstack/objectql` (full): 544/544, 10606. `@objectstack/metadata-protocol` (full): 308 passed | 4 skipped (312), 22262 passed | 26 skipped. - Every other test file outside `packages/spec` that names `objectExtensions`, `defineObjectExtension`, `object_extension` or `ObjectExtensionSchema`: `@objectstack/cli` 9/9 (108), `@objectstack/cloud-connection` 3/3 (19), `@objectstack/lint` 7/7 (535), `@objectstack/metadata` 2/2 (17), `@objectstack/platform-objects` 2/2 (73), `@objectstack/rest` 5/5 (81), `@objectstack/runtime` 4/4 (48). - Not run locally: the three `ui/packages/app-shell` files that name an extension (`ResourceListPage.objectExtensionName-29569`, `DataPillar.baseObjectExtension-28575`, `DataPillar.draftUnreadable-29148`). Their extension bodies are mock-server fixtures keyed `custom_note__c`, `budget_code__c`, `region__c`, `tier__c`, and none parses them with the spec; Test UI runs them. Studio's add-field derives the name through `toFieldNameLoose` and `extensionFieldName`, so it writes snake_case keys. </details> <details><summary>Derived gates</summary> `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` on the final head `bd50ef25`: 104 commands, all exit 0; `--ran` reconciliation: 104 derived, 104 run, 0 NOT-MEASURED. They ran after the full build, so the built-output gates (`check:skill-examples`, `check:dual-build-cjs-loads`) measured, where on `61876fc4` they had exited 3 for want of that build. `check-adr-0087-registration` (registered, new here) and `check-changeset-no-major` are among the 104. </details> <details><summary>Dogfood (narrowing arm, final head)</summary> `pnpm --filter @objectstack/dogfood test` under the lock on `bd50ef25`, after the full build in the same locked run: 402 passed | 3 skipped (405) files; 3317 passed | 28 skipped (3345) tests; exit 0, lock VERDICT command-exit 0. </details> ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. - [x] 2. Every name I added uses the casing for its kind. - [x] 3. No formatter ran; `pnpm lint` passes (CI). - [x] 4. One issue, one branch off an up-to-date `main`, one pull request. - [x] 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset. - [x] 6. `packages/spec` touched: its generated artifacts are regenerated and committed. - [x] 7. No hand edits to `content/docs/references/`, `packages/*/CHANGELOG.md` or `content/docs/releases/`. - [x] 8. Title, body and comments are in English. - [x] The *Before Submitting* list in CONTRIBUTING.md is done. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01WGufzf5kvTFdbWYDbe8bHE --- _Generated by [Claude Code](https://claude.ai/code/session_01WGufzf5kvTFdbWYDbe8bHE)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent b62463f commit eeb3117

8 files changed

Lines changed: 289 additions & 14 deletions

File tree

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
---
2+
'@objectstack/spec': minor
3+
---
4+
5+
feat(spec)!: an object extension's `fields` is keyed by the field map's own key grammar, so a field no object could declare is refused where it is written
6+
7+
`ObjectExtensionSchema.fields` was keyed by any string. The fields an extension adds are merged into the target object's field map, and every key of that map is lowercase snake_case: `ObjectSchema.fields` refuses any other. So an extension could add a field under a key (`Region Code`, `region-code`) that the target's own declaration could never hold.
8+
9+
Clause-②: no (narrowing) — `ObjectExtensionSchema` refuses a `fields` key outside the snake_case grammar of an object's `fields` keys, which it accepted. Nothing is widened, and no public export is added.
10+
11+
Metadata-protocol: narrowed (an object extension may no longer add a field under anything but a snake_case field name)
12+
13+
Skills-docs: updated here — `content/docs/data-modeling/object-extensions.mdx` ("Naming the fields you add") gains the key grammar. `skills/objectstack-data/SKILL.md` and its evals teach extension fields only as `region__c`, so every taught line stays true and no skill line changes.
14+
15+
<!-- adr-0087: registered object-extension-field-key-grammar-refused -->
16+
17+
**BREAKING** accept-set narrowing on `ObjectExtensionSchema` (`@objectstack/spec`), at every door that parses an object extension: `defineObjectExtension`, `defineStack({ objectExtensions })`, `os validate` and `os build`, and the `/api/v1/meta` `object_extension` save door. The marketplace install does not parse an extension with the schema (it judges a built artifact's `__c` suffixes and patch targets), so a package built after this change carries the refusal from `os build`.
18+
19+
- **Before.** `fields: { 'Region Code': { type: 'text', label: 'Region' } }` parsed in `defineStack`, whether or not the target was in the same stack, and `os validate` raised nothing on it. In a `.custom` package, `fields: { 'Region Code__c': … }` was saved and published by the `object_extension` doors, served on every object read, re-registered at boot, and stored values in a column of that name.
20+
- **Now.** One `invalid_key` issue at `fields.Region Code`, naming the grammar and an example. The key schema is the one `ObjectSchema.fields` and `fieldPatches` read, one constant, so the three cannot drift.
21+
- **Unchanged.** A snake_case key parses as before, `__c` included (`region_code__c`); where the `__c` suffix is required or refused is still judged by the doors' `__c` rule.
22+
- **Who sees it.** No extension in this repository's examples, skills, docs or templates writes such a key. A stored `object_extension` row that carries one no longer parses, so the sync that registers stored extensions counts it as an error and registers none of it, its `fieldPatches` included, and its next save is refused (422 `INVALID_METADATA`) until the field is renamed. `os migrate meta --stored` lists such a row beforehand, one TODO for each refused key at `fields.KEY`, and leaves it as stored for its owner to rename.
23+
24+
**FROM → TO.** `fields: { 'Region Code': { type: 'text', label: 'Region Code' } }` → `fields: { region_code: { type: 'text', label: 'Region Code' } }` (`region_code__c` in a customization package): key each added field by a lowercase snake_case name, and keep the display text in its `label`. Renaming a field that already stores values also means renaming every view, formula and caller that reads it. The upgrade guide entry is `object-extension-field-key-grammar-refused`.

‎content/docs/data-modeling/object-extensions.mdx‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,11 @@ reason.
160160

161161
## Naming the fields you add
162162

163+
Each key of an extension's `fields` is a field name of the target, so it follows the
164+
grammar of the target's own `fields` keys: lowercase snake_case, such as `region_code`
165+
(`region_code__c` in a `.custom` package). A key outside it (`Region Code`,
166+
`region-code`, a label) is refused when the extension parses, at `fields.<key>`.
167+
163168
Field names in an extension are **not** namespace-prefixed by the platform. The
164169
namespace rule (`<namespace>_<name>`) governs the names of objects a package *defines*;
165170
it does not walk `objectExtensions`, and it could not — the target's name belongs to

‎content/docs/references/data/object.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -577,7 +577,7 @@ const result = ApiMethod.parse(data);
577577
| Property | Type | Required | Description |
578578
| :--- | :--- | :--- | :--- |
579579
| **extend** | `string` | ✅ | Target object name (FQN) to extend |
580-
| **fields** | `Record<string, { name?: string; label?: string; type: Enum<'text' \| 'textarea' \| 'email' \| 'url' \| 'phone' \| 'password' \| 'secret' \| …>; description?: string; … }>` | optional | Fields to add/override |
580+
| **fields** | `Record<string, { name?: string; label?: string; type: Enum<'text' \| 'textarea' \| 'email' \| 'url' \| 'phone' \| 'password' \| 'secret' \| …>; description?: string; … }>` | optional | Fields to add/override, keyed by field name (lowercase snake_case, the key grammar of an object's `fields`). |
581581
| **fieldPatches** | `Record<string, { label?: string; description?: string; inlineHelpText?: string; placeholder?: string; … }>` | optional | Display patches of the target object's own fields, keyed by field name (lowercase snake_case, the key grammar of an object's `fields`): change a vendor field's display properties (BASE_FIELD_DISPLAY_WHITELIST: `label`, `description`, `inlineHelpText`, `placeholder` on the field, `label` on an option under `options`, keyed by its stored value) without taking the field over, so its type, values and rules stay the vendor's and its upgrades still apply (ADR-0146 D10). Applied by the registry merge (`mergeObjectDefinitions`) after the additive merges, on every object read: a tenant's own text keeps winning, and between two extensions the higher `priority` wins. A patch naming a field or option value the target does not have is refused at install and by `os validate`; a field the same extension replaces in `fields` is refused here. Example: `fieldPatches: { industry: { label: 'Sector', inlineHelpText: 'The market the account sells into', options: { technology: { label: 'Tech' } } } }`. |
582582
| **label** | `string` | optional | Override label for the extended object |
583583
| **pluralLabel** | `string` | optional | Override plural label for the extended object |

‎packages/spec/src/conversions/refused-shapes.test.ts‎

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ describe('the refused-shape list — registered against the migration chain', ()
7575
'ui-dashboard-global-filter-duplicate-name-refused',
7676
'ui-dashboard-global-filter-reserved-date-range-refused',
7777
'object-extension-field-patch-key-grammar-refused',
78+
'object-extension-field-key-grammar-refused',
7879
]);
7980
});
8081

@@ -993,3 +994,61 @@ describe('#29554 — a stored object extension whose fieldPatches key cannot be
993994
expect(patchKeyIssues(row)).toEqual([]);
994995
});
995996
});
997+
998+
// ── #29523 (the extension `fields` key grammar) ───────────────────────────
999+
1000+
const fieldRow = (fields: Record<string, unknown>) => ({ extend: 'crm_account', fields });
1001+
/** The `invalid_key` issues the extension parse locates at `fields.KEY`, as `[path, message]`. */
1002+
const fieldKeyIssues = (row: unknown) => {
1003+
const parsed = ObjectExtensionSchema.safeParse(row);
1004+
return parsed.success
1005+
? []
1006+
: parsed.error.issues
1007+
.filter((i) => i.code === 'invalid_key' && i.path[0] === 'fields')
1008+
.map((i) => [i.path.join('.'), i.message]);
1009+
};
1010+
1011+
describe('#29523 — a stored object extension whose fields key cannot be a field name is listed by extension', () => {
1012+
it('one TODO per refused key, at fields.KEY, naming the target; the row is not rewritten', () => {
1013+
const row = fieldRow({
1014+
'Region Code': { type: 'text', label: 'Region' },
1015+
'region-tier': { type: 'number', label: 'Tier' },
1016+
region_owner: { type: 'text', label: 'Owner' },
1017+
});
1018+
const { item, todos } = storedTodos('object_extension', row);
1019+
expect(item).toEqual(row);
1020+
expect(todos.map((t) => [t.code, t.conversionId, t.path])).toEqual([
1021+
[CONVERSION_TODO_CODE, 'object-extension-field-key-grammar-refused', 'objectExtensions[0].fields.Region Code'],
1022+
[CONVERSION_TODO_CODE, 'object-extension-field-key-grammar-refused', 'objectExtensions[0].fields.region-tier'],
1023+
]);
1024+
const todo = todos[0]!;
1025+
expect(todo.reason).toMatch(/^The extension of `crm_account` adds a field under `Region Code`\. No lossless rewrite:/);
1026+
expect(todo.reason).not.toMatch(/#\d{3,}/);
1027+
expect(JSON.parse(todo.from)).toEqual({ 'Region Code': { type: 'text' } });
1028+
});
1029+
1030+
it('agrees with the refusing door: each listed key is one the extension parse refuses as a key, and no other', () => {
1031+
const row = fieldRow({ RegionCode: { type: 'text', label: 'R' }, '2nd_phone': { type: 'phone', label: 'P' }, region: { type: 'text', label: 'R' } });
1032+
const { todos } = storedTodos('object_extension', row);
1033+
const issues = fieldKeyIssues(row);
1034+
expect(issues.map(([path]) => path)).toEqual(['fields.RegionCode', 'fields.2nd_phone']);
1035+
expect(todos.map((t) => t.path)).toEqual(issues.map(([path]) => `objectExtensions[0].${path}`));
1036+
// The door's own message closes each reason, so the listing says what the parse says.
1037+
todos.forEach((t, i) => expect(t.reason.endsWith(issues[i]![1]!)).toBe(true));
1038+
});
1039+
1040+
it('snake_case keys, `__c` ones included, are not listed, and the parse accepts them', () => {
1041+
const row = fieldRow({ region_code: { type: 'text', label: 'Region' }, region_code__c: { type: 'text', label: 'Region' } });
1042+
expect(storedTodos('object_extension', row).todos).toEqual([]);
1043+
expect(ObjectExtensionSchema.safeParse(row).success).toBe(true);
1044+
});
1045+
1046+
it('each record is listed by its own detector: a refused fieldPatches key is not listed as a field, nor the reverse', () => {
1047+
const row = { extend: 'crm_account', fields: { 'Region Code': { type: 'text', label: 'R' } }, fieldPatches: { Industry: { label: 'Sector' } } };
1048+
const { todos } = storedTodos('object_extension', row);
1049+
expect(todos.map((t) => [t.conversionId, t.path])).toEqual([
1050+
['object-extension-field-patch-key-grammar-refused', 'objectExtensions[0].fieldPatches.Industry'],
1051+
['object-extension-field-key-grammar-refused', 'objectExtensions[0].fields.Region Code'],
1052+
]);
1053+
});
1054+
});

‎packages/spec/src/conversions/refused-shapes.ts‎

Lines changed: 58 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -46,9 +46,9 @@
4646
* judge of the object refinement ({@link duplicateActionNames}), and the
4747
* one-key-per-global-filter judge of the dashboard refinement
4848
* ({@link duplicateGlobalFilterNames}), and its reserved-`dateRange` judge
49-
* ({@link reservedDateRangeGlobalFilters}), and the `fieldPatches` key
50-
* grammar of `ObjectExtensionSchema`'s own parse
51-
* ({@link refusedFieldPatchKeys}). A site is listed here exactly
49+
* ({@link reservedDateRangeGlobalFilters}), and the `fieldPatches` and
50+
* `fields` key grammar of `ObjectExtensionSchema`'s own parse
51+
* ({@link refusedFieldPatchKeys}, {@link refusedExtensionFieldKeys}). A site is listed here exactly
5252
* when that door refuses it. One door lives outside this package: the
5353
* page/view `visibleWhen` rule in `@objectstack/lint` judges a `has()`
5454
* argument on the engine's AST, which this package cannot parse, so its
@@ -628,12 +628,25 @@ const dashboardReservedDateRangeGlobalFilter: RefusedStoredShape = {
628628
* a key is listed here exactly when that parse refuses it as a key.
629629
*/
630630
function refusedFieldPatchKeys(extension: Dict): Array<{ key: string; message: string }> {
631-
if (!isDict(extension.fieldPatches)) return [];
631+
return refusedExtensionRecordKeys(extension, 'fieldPatches');
632+
}
633+
634+
/**
635+
* The keys of one field-name-keyed record of an object extension (`fields` or
636+
* `fieldPatches`) that `ObjectExtensionSchema` refuses as no field name: each
637+
* `invalid_key` issue the extension's own parse locates at `SLOT.KEY`, with
638+
* that issue's message.
639+
*/
640+
function refusedExtensionRecordKeys(
641+
extension: Dict,
642+
slot: 'fields' | 'fieldPatches',
643+
): Array<{ key: string; message: string }> {
644+
if (!isDict(extension[slot])) return [];
632645
const parsed = ObjectExtensionSchema.safeParse(extension);
633646
if (parsed.success) return [];
634647
const refused: Array<{ key: string; message: string }> = [];
635648
for (const issue of parsed.error.issues) {
636-
if (issue.code !== 'invalid_key' || issue.path.length !== 2 || issue.path[0] !== 'fieldPatches') continue;
649+
if (issue.code !== 'invalid_key' || issue.path.length !== 2 || issue.path[0] !== slot) continue;
637650
const key = issue.path[1];
638651
if (typeof key === 'string') refused.push({ key, message: issue.message });
639652
}
@@ -665,6 +678,45 @@ const objectExtensionFieldPatchKeyGrammar: RefusedStoredShape = {
665678
},
666679
};
667680

681+
// ── #29523: an object extension whose `fields` key cannot be a field name ──
682+
683+
/**
684+
* The `fields` keys `ObjectExtensionSchema` refuses as no field name, read as
685+
* {@link refusedFieldPatchKeys} reads `fieldPatches`: the extension's own
686+
* parse, the one the save door and the sync that registers stored extensions
687+
* run, so a key is listed here exactly when that parse refuses it as a key.
688+
*/
689+
function refusedExtensionFieldKeys(extension: Dict): Array<{ key: string; message: string }> {
690+
return refusedExtensionRecordKeys(extension, 'fields');
691+
}
692+
693+
const objectExtensionFieldKeyGrammar: RefusedStoredShape = {
694+
id: 'object-extension-field-key-grammar-refused',
695+
toMajor: 18,
696+
surface: 'objectExtensions[].fields — a key outside the snake_case grammar of an object\'s `fields` keys',
697+
detect(stack, report) {
698+
mapCollection(stack, 'objectExtensions', (extension, path) => {
699+
const target = typeof extension.extend === 'string' ? `\`${extension.extend}\`` : 'its target';
700+
for (const { key, message } of refusedExtensionFieldKeys(extension)) {
701+
report({
702+
path: `${path}.fields.${key}`,
703+
// The key and the field's type only: the body is the author's, the key is what is refused.
704+
from: JSON.stringify({ [key]: { type: ((extension.fields as Dict)[key] as Dict | undefined)?.type } }),
705+
reason: `The extension of ${target} adds a field under \`${key}\`. `
706+
+ 'No lossless rewrite: the key is the name the field is stored and read under, and re-spelling it '
707+
+ '(lower-casing it, turning `-` or a space into `_`) names a different column than the one rows, '
708+
+ 'views and formulas may already read, or lands on a field the object already declares, so the '
709+
+ 'name is the author\'s to choose. Left as stored, the row no longer parses: the sync that registers '
710+
+ 'stored extensions counts it as an error and registers none of it, its `fieldPatches` included, '
711+
+ 'and its next save is refused with 422 INVALID_METADATA until the field is renamed. '
712+
+ message,
713+
});
714+
}
715+
return extension;
716+
});
717+
},
718+
};
719+
668720
/**
669721
* Every refused stored shape, in report order. A new entry is a reviewed edit:
670722
* `refused-shapes.test.ts` pins each one.
@@ -680,6 +732,7 @@ export const REFUSED_STORED_SHAPES: readonly RefusedStoredShape[] = [
680732
dashboardDuplicateGlobalFilterName,
681733
dashboardReservedDateRangeGlobalFilter,
682734
objectExtensionFieldPatchKeyGrammar,
735+
objectExtensionFieldKeyGrammar,
683736
];
684737

685738
/**

‎packages/spec/src/data/field-customization.test.ts‎

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -411,3 +411,67 @@ describe('`fieldPatches` keys — the field map\'s own key grammar', () => {
411411
}
412412
});
413413
});
414+
415+
/** Parse an extension whose `fields` is `fields`; return the issues. */
416+
function extensionFieldsIssues(fields: Record<string, unknown>): Issue[] {
417+
const r = ObjectExtensionSchema.safeParse({ extend: 'crm_account', fields });
418+
return r.success ? [] : (r.error.issues as unknown as Issue[]);
419+
}
420+
421+
describe('extension `fields` keys — the field map\'s own key grammar', () => {
422+
it.each([['Region Code'], ['RegionCode'], ['region-code'], ['2nd_phone'], ['Region Code__c']])(
423+
'refuses `%s`, which no field name can be: one `invalid_key` at its key, naming the grammar and an example',
424+
(key) => {
425+
const issues = extensionFieldsIssues({ [key]: { type: 'text', label: 'Region' } });
426+
expect(issues).toHaveLength(1);
427+
expect(issues[0]!.code).toBe('invalid_key');
428+
expect(issues[0]!.path).toEqual(['fields', key]);
429+
expect(issues[0]!.message.startsWith(`\`${key}\` cannot be a field name`)).toBe(true);
430+
expect(issues[0]!.message).toContain('lowercase snake_case such as `region_code`');
431+
expect(issues[0]!.message).not.toMatch(/#\d{3,}/);
432+
},
433+
);
434+
435+
it('control: a snake_case key parses, and so does one spelled `__c` (the suffix is judged at the doors)', () => {
436+
expect(extensionFieldsIssues({ region_code: { type: 'text', label: 'Region' } })).toEqual([]);
437+
expect(extensionFieldsIssues({ region_code__c: { type: 'text', label: 'Region' } })).toEqual([]);
438+
});
439+
440+
it('control: the authored examples of the object-extension guide still parse whole', () => {
441+
// `content/docs/data-modeling/object-extensions.mdx`, the declaration and the worked example.
442+
const declared = ObjectExtensionSchema.parse({
443+
extend: 'showcase_account',
444+
fields: {
445+
loyalty_tier: { type: 'select', label: 'Loyalty Tier', options: [{ label: 'Gold', value: 'gold' }] },
446+
csat_score: { type: 'number', label: 'CSAT Score', min: 0, max: 100 },
447+
},
448+
priority: 210,
449+
});
450+
expect(Object.keys(declared.fields ?? {})).toEqual(['loyalty_tier', 'csat_score']);
451+
const worked = ObjectExtensionSchema.parse({
452+
extend: 'crm_account',
453+
fields: {
454+
cs_health_score: { type: 'number', label: 'Health Score', min: 0, max: 100 },
455+
cs_renewal_date: { type: 'date', label: 'Renewal Date' },
456+
cs_owner: { type: 'text', label: 'Success Manager' },
457+
},
458+
indexes: [{ name: 'idx_crm_account_cs_renewal', fields: ['cs_renewal_date'] }],
459+
priority: 300,
460+
});
461+
expect(Object.keys(worked.fields ?? {})).toEqual(['cs_health_score', 'cs_renewal_date', 'cs_owner']);
462+
});
463+
464+
it('the key schema IS the one `ObjectSchema.fields` reads: both admit exactly the same grammar', () => {
465+
// `constructor` / `prototype` are left out: `ObjectSchema.fields` refuses them by record refines, outside the key schema.
466+
const keys = ['region', 'region_code', '_internal', 'region__c', 'a1', 'Region', 'Region Code', 'region-code', '2nd_phone', 'regionCode', ''];
467+
const admitted = keys.filter((key) => objectFieldsAdmit(key));
468+
// Guard against a vacuous agreement: the sample holds keys on both sides of the grammar.
469+
expect(admitted.length).toBeGreaterThan(0);
470+
expect(admitted.length).toBeLessThan(keys.length);
471+
for (const key of keys) {
472+
const refusedAtKey = extensionFieldsIssues({ [key]: { type: 'text', label: 'L' } })
473+
.some((i) => i.code === 'invalid_key' && i.path.join('.') === `fields.${key}`);
474+
expect(refusedAtKey, key).toBe(!admitted.includes(key));
475+
}
476+
});
477+
});

0 commit comments

Comments
 (0)