Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions .changeset/28655-field-patches-refuse-replaced-field.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
---
'@objectstack/spec': minor
---

feat(spec)!: an object extension's `fieldPatches` refuses a field the same extension replaces in `fields`, keys option patches by the option-value grammar, refuses `__proto__`, and carries the field's and option's display renames

`ObjectExtensionSchema.fieldPatches` (ADR-0146 D10) is applied by the registry merge, so what it accepts now changes what every read serves. Three things it accepted are refused, each located at its key:

- a field named in both `fields` and `fieldPatches` of one extension (`fieldPatches.industry`): `fields` takes the vendor's field over whole, so the patch landed on the extension's own replacement and the author got neither answer;
- an option-patch key no option `value` can be (`fieldPatches.industry.options.Tech Sector`): the map is now keyed by `SelectOptionSchema.shape.value` itself, so the patch can address only an option that can exist;
- a `__proto__` own key in `fields`, `fieldPatches` or an option-patch map, which zod's record parser dropped while reporting success. Measured at the `/api/v1/meta` save door: the body parsed, the stored row kept the key, and the registered extension did not.

A field patch that spells a key the way the field's own rename table knows (`helpText`, `help`, `hint` or `tooltip` for `inlineHelpText`; `title` or `displayName` for `label`), and an option patch that spells one the option's table knows (`text`, `name` or `title` for `label`), now gets the rename hint instead of a bare unknown-key refusal. The rows are taken by filter from the one table each source shape reads, so a rename onto a whitelisted key reaches the patch the day it lands. The `fieldPatches` description lists its members from `BASE_FIELD_DISPLAY_WHITELIST`.

Clause-②: no (narrowing) — `ObjectExtensionSchema` refuses three inputs it accepted: a field both replaced and patched, an option-patch key outside the option-value grammar, and a `__proto__` own key in its three record slots. Nothing is widened: the renames are hints on keys already refused.

Metadata-protocol: narrowed (an object extension may no longer patch a field it replaces in `fields`, nor key an option patch by anything but a valid option value)

<!-- adr-0087: not-required (no-migration-prescription) No key of any schema is added, removed or renamed. A field both replaced and patched has two opposite intents and no rewrite keeps both; an option-patch key outside the value grammar never addressed an option; a __proto__ key never survived the parse. The author decides, and each refusal names the key. -->

**BREAKING** accept-set narrowing on `ObjectExtensionSchema` (`@objectstack/spec`), at every door that parses an object extension: `defineObjectExtension`, `defineStack({ objectExtensions })`, `os validate`, the marketplace install and the `/api/v1/meta` `object_extension` save door.

- **Before.** `{ extend: 'crm_account', fields: { industry: {…} }, fieldPatches: { industry: { label: 'Sector' } } }` parsed, and the patch relabelled the extension's replacement. `fieldPatches: { industry: { options: { 'Tech Sector': { label: 'Tech' } } } }` parsed and could never match an option. A JSON body carrying `"__proto__"` under `fields` or `fieldPatches` parsed with that key silently missing.
- **Now.** Each is refused: `custom` at `fieldPatches.industry`, `invalid_key` at `fieldPatches.industry.options.Tech Sector`, `custom` at `fields.__proto__` / `fieldPatches.__proto__`.
- **Unchanged.** A patch of a field the extension does not replace, keyed by a valid option value, parses as before.
- **Who sees it.** No extension in this repository's examples, skills or docs writes any of the refused forms.

**The remedy.** Choose one answer per field: keep the patch to change only its display (the vendor's type, values and upgrades still apply), or keep the `fields` entry and set the display properties there. Key an option patch by the option's stored `value` (`technology`), not its label. Rename a `__proto__` key.
6 changes: 6 additions & 0 deletions content/docs/data-modeling/object-extensions.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,12 @@ reason.
sit in the owner's stored declaration, so the install door admits a patch of one and
every read applies it, while `os validate`, which reads the declaration you wrote,
refuses it.
- **Patch it or take it over, not both.** An extension that names one field in both
`fields` and `fieldPatches` is refused when it parses: `fields` takes the owner's field
over whole, so the patch would land on your own replacement and the owner's field would
reach neither. Keep the patch to change only its display, or keep the `fields` entry
and set the text there. An option is addressed by its stored `value`, so a key no value
can be (`Tech Sector`, a label) is refused too.
- **Conflicts resolve like `label`.** Two extensions patching the same key: the higher
`priority` wins. A tenant who has edited that text keeps their own, [below](#extensions-and-tenant-customization).
- **A patch must name something real.** A field the target does not declare, or an option
Expand Down
2 changes: 1 addition & 1 deletion content/docs/references/data/object.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -577,7 +577,7 @@ const result = ApiMethod.parse(data);
| :--- | :--- | :--- | :--- |
| **extend** | `string` | ✅ | Target object name (FQN) to extend |
| **fields** | `Record<string, { name?: string; label?: string; type: Enum<'text' \| 'textarea' \| 'email' \| 'url' \| 'phone' \| 'password' \| 'secret' \| …>; description?: string; … }>` | optional | Fields to add/override |
| **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: change a vendor field's display properties (BASE_FIELD_DISPLAY_WHITELIST: label, description, help text, placeholder, option labels) 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`. Example: `fieldPatches: { industry: { label: 'Sector', inlineHelpText: 'The market the account sells into', options: { technology: { label: 'Tech' } } } }`. |
| **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: 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' } } } }`. |
| **label** | `string` | optional | Override label for the extended object |
| **pluralLabel** | `string` | optional | Override plural label for the extended object |
| **description** | `string` | optional | Override description for the extended object |
Expand Down
10 changes: 5 additions & 5 deletions packages/spec/dropped-refinements.baseline.json
Original file line number Diff line number Diff line change
Expand Up @@ -1011,11 +1011,11 @@
"data/ObjectExtension": {
"sites": [
"",
"fields.valueType",
"fields.valueType.currencyConfig",
"fields.valueType.inlineColumns.element",
"fields.valueType.relatedListFilter.lazy",
"fields.valueType.summaryOperations"
"fields.out.valueType",
"fields.out.valueType.currencyConfig",
"fields.out.valueType.inlineColumns.element",
"fields.out.valueType.relatedListFilter.lazy",
"fields.out.valueType.summaryOperations"
]
},
"data/PostgresConfig": {
Expand Down
130 changes: 130 additions & 0 deletions packages/spec/src/data/field-customization.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -218,3 +218,133 @@ describe('`ObjectExtensionSchema.fieldPatches` — the per-field display patch s
expect(description).toContain('mergeObjectDefinitions');
});
});

// ---------------------------------------------------------------------------
// What the slot refuses beyond its key sets (ADR-0146 D10): a field the same
// extension replaces, an option key no option can carry, and `__proto__`; and
// the renames it carries from the shapes it patches. Each refusal names its
// code, path and subject, beside a control through the same parse.
// ---------------------------------------------------------------------------

/** Parse an extension body as the save door does: a JSON document, so `__proto__` is an own key. */
function jsonIssues(json: string): Issue[] {
const r = ObjectExtensionSchema.safeParse(JSON.parse(json));
return r.success ? [] : (r.error.issues as unknown as Issue[]);
}

const INDUSTRY_FIELD = { name: 'industry', type: 'text', label: 'Industry' } as const;

describe('`fieldPatches` — what the slot refuses beyond its keys, and the renames it carries', () => {
it('refuses a field the same extension both replaces in `fields` and patches: patch it or declare your own, not both', () => {
const r = ObjectExtensionSchema.safeParse({
extend: 'crm_account',
fields: { industry: INDUSTRY_FIELD },
fieldPatches: { industry: { label: 'Sector' } },
});
expect(r.success).toBe(false);
const issues = r.success ? [] : (r.error.issues as unknown as Issue[]);
expect(issues).toHaveLength(1);
expect(issues[0]!.code).toBe('custom');
expect(issues[0]!.path).toEqual(['fieldPatches', 'industry']);
expect(issues[0]!.message).toMatch(/^`industry` is both replaced in `fields` and patched in `fieldPatches`/);
expect(issues[0]!.message).toContain('not both');
});

it('control: a patch beside a `fields` entry of ANOTHER name parses', () => {
const r = ObjectExtensionSchema.safeParse({
extend: 'crm_account',
fields: { region: { name: 'region', type: 'text', label: 'Region' } },
fieldPatches: { industry: { label: 'Sector' } },
});
expect(r.success).toBe(true);
});

it('a `__c` key in both is refused once, by the `__c` rule, whose prescription already names `fields`', () => {
const r = ObjectExtensionSchema.safeParse({
extend: 'crm_account',
fields: { region__c: { name: 'region__c', type: 'text', label: 'Region' } },
fieldPatches: { region__c: { label: 'Region' } },
});
const issues = r.success ? [] : (r.error.issues as unknown as Issue[]);
expect(issues.map((i) => [i.code, i.path.join('.')])).toEqual([['custom', 'fieldPatches.region__c']]);
expect(issues[0]!.message).toContain('spelled as an extension field');
});

it('refuses an option key no option can carry: the map is keyed by `SelectOptionSchema.shape.value`', () => {
const issues = patchIssues({ options: { 'Tech Sector': { label: 'Tech' } } });
expect(issues).toHaveLength(1);
expect(issues[0]!.code).toBe('invalid_key');
expect(issues[0]!.path).toEqual(['fieldPatches', 'industry', 'options', 'Tech Sector']);
expect(issues[0]!.message).toMatch(/^`Tech Sector` cannot be an option's stored value/);
});

it('control: every key the option `value` grammar admits is accepted, and the key schema IS that grammar', () => {
expect(patchIssues({ options: { technology: { label: 'Tech' }, 'sector.retail_2': { label: 'Retail' } } })).toEqual([]);
for (const key of ['technology', 'Tech Sector', 'x', '2nd']) {
expect(patchIssues({ options: { [key]: { label: 'L' } } }).length === 0, key)
.toBe(SelectOptionSchema.shape.value.safeParse(key).success);
}
});

it.each([
['fields', '{"extend":"crm_account","fields":{"__proto__":{"name":"x","type":"text","label":"X"}}}', ['fields', '__proto__']],
['fieldPatches', '{"extend":"crm_account","fieldPatches":{"__proto__":{"label":"X"}}}', ['fieldPatches', '__proto__']],
['options', '{"extend":"crm_account","fieldPatches":{"industry":{"options":{"__proto__":{"label":"X"}}}}}', ['fieldPatches', 'industry', 'options', '__proto__']],
])('refuses a `__proto__` own key in `%s`, which zod would drop while reporting success', (slot, json, path) => {
const issues = jsonIssues(json);
expect(issues).toHaveLength(1);
expect(issues[0]!.code).toBe('custom');
expect(issues[0]!.path).toEqual(path);
expect(issues[0]!.message).toMatch(new RegExp(`^\`${slot}\` cannot contain a key named "__proto__"`));
});

it('control: the same JSON documents without `__proto__` parse, and keep every key', () => {
const parsed = ObjectExtensionSchema.parse(JSON.parse(
'{"extend":"crm_account","fields":{"region":{"name":"region","type":"text","label":"Region"}},' +
'"fieldPatches":{"industry":{"options":{"technology":{"label":"Tech"}}}}}',
));
expect(Object.keys(parsed.fields ?? {})).toEqual(['region']);
expect(parsed.fieldPatches?.industry?.options).toEqual({ technology: { label: 'Tech' } });
});

it.each([
['helpText', 'inlineHelpText'],
['tooltip', 'inlineHelpText'],
['title', 'label'],
['displayName', 'label'],
])('a field patch carries the field\'s rename `%s` → `%s`', (written, target) => {
const issues = patchIssues({ [written]: 'Display text' });
expect(issues).toHaveLength(1);
expect(issues[0]!.code).toBe('unrecognized_keys');
expect(issues[0]!.keys).toEqual([written]);
expect(issues[0]!.message).toContain(`\`${written}\` → \`${target}\``);
});

it.each([['text'], ['name'], ['title']])('an option patch carries the option\'s rename `%s` → `label`', (written) => {
const issues = patchIssues({ options: { technology: { [written]: 'Tech' } } });
expect(issues).toHaveLength(1);
expect(issues[0]!.code).toBe('unrecognized_keys');
expect(issues[0]!.path).toEqual(['fieldPatches', 'industry', 'options', 'technology']);
expect(issues[0]!.message).toContain(`\`${written}\` → \`label\``);
});

it('control: a rename whose target the patch refuses is not carried (no `isRequired` → `required`, no `key` → `value`)', () => {
const field = patchIssues({ isRequired: true });
expect(field.map((i) => i.code)).toEqual(['unrecognized_keys']);
expect(field[0]!.message).not.toContain('→');
const option = patchIssues({ options: { technology: { key: 'tech' } } });
expect(option.map((i) => i.code)).toEqual(['unrecognized_keys']);
expect(option[0]!.message).not.toContain('→');
// `colour` → `color` is carried only once the whitelist admits `color` (`field-patches-derived.test.ts`).
const colour = patchIssues({ options: { technology: { colour: '#2563eb' } } });
expect(colour.map((i) => i.code)).toEqual(['unrecognized_keys']);
expect(colour[0]!.message).not.toContain('`colour` → `color`');
});

it('the description lists the whitelist\'s members, read from the constant', () => {
const description = shapeOf(ObjectExtensionSchema).fieldPatches!.description ?? '';
for (const key of [...BASE_FIELD_DISPLAY_WHITELIST.field, ...BASE_FIELD_DISPLAY_WHITELIST.option]) {
expect(description, key).toContain(`\`${key}\``);
}
});
});
Loading
Loading