Skip to content

Commit 386f60f

Browse files
fix(spec)!: the Field.select string shorthand keeps what it does not document transforming, so a hyphenated option is refused instead of declared as another value (#25122)
Fixes #25013 Clause-②: no (narrowing: a `Field.select` shorthand string the helper used to rewrite silently, e.g. `'no-overlay'` → `nooverlay`, is refused instead) Metadata-protocol: narrowed ## What changed - **`packages/spec/src/data/field.zod.ts` `selectWithOptions`**: the string shorthand's transform is now only what its JSDoc documents: lower-case, and whitespace runs become `_`. It no longer deletes every other character outside `a-z0-9_`. A shorthand string that is no system identifier (`'no-overlay'`) therefore reaches `SelectOptionSchema.value` unchanged and is refused by the rule an explicit `{ value: 'no-overlay' }` already met. No new error code, no parallel validator, and `SelectOptionSchema.value` / `SystemIdentifierSchema` accept and refuse exactly what they did before. The explicit-object branch keeps its lower-casing (documented, and not a strip). The `Field.select` JSDoc states the rule and the explicit spelling to use. - **`packages/metadata-core` `sys_metadata_audit.lock_state`**: the one in-tree shorthand the strip altered. It declared `none`, `nooverlay`, `nodelete`, `full`, while its writer (`metadata-protocol` `protocol.ts`, `lock_state: entry.lockState ?? 'none'`) stores the `MetadataLock` spelling (`no-overlay`, `no-delete`). A select option value cannot hold a hyphen, so the column is now `Field.text({ maxLength: 255 })`, read-only as before, like the sibling `code` column whose closed set also lives with its writer. Writer and reader unchanged, no stored row rewritten. - **Translations** (`platform-objects`, regenerated with `node scripts/check-i18n-bundles.mjs --write`): the four `lock_state` option labels drop out; the field label stays. - **ADR-0087**: a semantic entry `field-select-shorthand-keeps-written-value` (major 18), so the upgrade guide and `os migrate meta` carry the TODO for a TypeScript author whose shorthand now refuses. The changeset's disposition is `registered`. - **Engine-double ledger**: `scripts/engine-double-contract.pinned.json` records the new save-door test's fake engine (`check-engine-double-contract.mjs --write`). ## Premises verified before writing code (ruling part ②) - **(a) `lock_state` is the only shorthand the strip alters.** A TypeScript-AST census of every `Field.select(...)` call in all 9,792 tracked `.ts/.tsx/.mts/.js/.mjs` files at `ae58b26f9` (140 calls, 189 shorthand strings), comparing the old strip with the documented transform and with the identifier rule: 2 hits, both `sys-metadata-audit.object.ts:190` (`no-overlay`, `no-delete`). The non-literal option lists it could not read statically (`BUILTIN_MEMBERSHIP_ROLE_OPTIONS`, `InvitationStatus.options`, `SYS_ACTIVITY_BUILTIN_TYPES`, and the `.map(...)` builders that write explicit values) were read by hand: all identifiers. A regex census of the 1,016 tracked `.md/.mdx` files (published skills, docs, templates; changelogs excluded): 51 shorthand strings, 0 hits. **Positive control**: the same AST census at `ba244f1^` (before PR #25099) reports the third hit, `sys-oauth-application.object.ts:449` `'user-agent-based'`. - **(b) `lock_state` can hold what its writer writes without widening the option-value accept set and without rewriting rows.** `Field.text` holds the hyphenated spelling; no option value is involved. Schema drift was measured with `diffManagedTable` on a `varchar(255)` column (the width the select column was created as) on postgres, mysql and sqlite: `select` 0 entries; `text` with no `maxLength` 1 entry on postgres and mysql (`manual_widen_varchar_to_text`, `needs_confirm`); `text` with `maxLength: 32` a destructive `narrow_varchar`; `text` with `maxLength: 255` 0 entries on all three. Hence `maxLength: 255`. ## Where the refusal fires (H3, measured) - **TypeScript authoring, module load**: `ObjectSchema.create` (the shape every platform object and `os init` template uses) throws a `ZodError` with one issue, `invalid_format` at `fields.FIELD.options.N.value`, message `System identifier must be lowercase, starting with a letter, …`. Before the `lock_state` fix, importing `sys-metadata-audit.object.ts` would have thrown exactly this. - **The object save door**: `saveMetaItem` answers `422 INVALID_METADATA` with the issue at `fields.status.options.1.value`, code `invalid_format`, and stores nothing; the shorthand and the explicit spelling produce identical `issues`. ## Tests - `packages/spec/src/data/field.test.ts`: the hyphen is kept; shorthand and explicit meet the same issue at the same path; `ObjectSchema.create` refuses at module load (`invalid_format` at `fields.lock_state.options.1.value`); the documented transform still parses (`In Progress` → `in_progress`, and `v1.2` now declares `v1.2`). - `packages/metadata-protocol/src/protocol.select-shorthand-refusal.test.ts`: the save door's `code` + `status` + issue path, the explicit spelling's identical issues, and a control that the documented transform saves. - `packages/metadata-core/src/objects/sys-metadata-audit-lock-state.test.ts`: `lock_state` is text, `maxLength: 255`, no options, read-only; exactly `no-overlay` and `no-delete` of `MetadataLockSchema` are values a select option cannot declare; the object parses. - **Ablation** (one, on the behaviour the spec tests pin): `scripts/ablation-replace.mjs` re-inserted the strip into `field.zod.ts` (anchor 1 → 0, blob `9d7a024c0fbc` → `3162adcde7d2`); `field.test.ts` went 4 failed / 280 passed, the four new cases; restored, `git diff HEAD` empty. - Suites (on `88440a148`, before the ledger entry and merge; neither touches these packages' sources): `@objectstack/spec` 706 files / 21,463 tests passed; `@objectstack/metadata-core` 19 / 791; `@objectstack/metadata-protocol` 252 passed + 3 skipped files / 20,835 passed + 19 skipped tests; `@objectstack/platform-objects` 86 / 1,250. After the ledger entry: spec `src/migrations` + `field.test.ts` 6 files / 512 tests, and `typecheck` green for spec, metadata-core, metadata-protocol and platform-objects. - Full-repo pin sweep: `git grep` for `nooverlay`, `nodelete`, `useragentbased` and `toSnakeCase` outside changelogs finds only the regenerated translation bundles, the earlier OAuth changeset and the CLI's own unrelated `toSnakeCase`. No fixture asserted the stripped values, so no pin flips. ## Acceptance notes - The translated `lock_state` option labels (`禁止叠加`, …) never displayed: they were keyed `nooverlay` / `nodelete`, which no stored row holds. Dropping them changes nothing a user saw; the column now shows the stored spelling. - `'v1.2'` used to declare `v12` and now declares `v1.2`, a valid identifier: a value change, not a refusal. The census found no in-tree shorthand with a dot. - The explicit-object branch still lower-cases `value` (`{ value: 'Active' }` → `active`). That is documented and not a strip, so it is left alone here; recorded in the report as an open question. --- _Generated by [Claude Code](https://claude.ai/code/session_0123qkvzKLQfRRRnuR8wanHJ)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent db9c02a commit 386f60f

12 files changed

Lines changed: 304 additions & 36 deletions
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
---
2+
'@objectstack/spec': minor
3+
'@objectstack/metadata-core': minor
4+
'@objectstack/platform-objects': patch
5+
---
6+
7+
fix(spec)!: the `Field.select` string shorthand keeps every character it does not document transforming, so an option such as `'no-overlay'` is refused instead of being declared as a different value
8+
9+
Clause-②: no (narrowing: a `Field.select` shorthand string the helper used to rewrite silently, e.g. `'no-overlay'` → `nooverlay`, is refused instead)
10+
11+
Metadata-protocol: narrowed
12+
13+
<!-- adr-0087: registered field-select-shorthand-keeps-written-value -->
14+
15+
**BREAKING** (a TypeScript author's shorthand that used to be rewritten is now refused), shipped as `minor` under the launch-window convention for breaking changes.
16+
17+
`Field.select(['a', 'b'])` and `Field.select({ options: ['a', 'b'] })` build each option as `{ label: s, value: … }`. The value used to be the string lower-cased, with whitespace turned into `_`, and then with every character outside `a-z`, `0-9` and `_` deleted. That last step was undocumented and silent: `'no-overlay'` became `nooverlay`, `'user-agent-based'` became `useragentbased`, `'v1.2'` became `v12`, and the field declared a value its author never wrote, so a record written with the value as written was refused with `invalid_option`.
18+
19+
- **FROM:** a shorthand string is lower-cased, whitespace becomes `_`, and every other character outside `a-z0-9_` is deleted. `Field.select(['no-overlay'])` declares the option value `nooverlay`, and the object loads.
20+
- **TO:** a shorthand string is lower-cased and whitespace becomes `_`, nothing else. `Field.select(['no-overlay'])` declares `no-overlay`, which is not a system identifier, so the object is refused by the rule an explicit `{ value: 'no-overlay' }` already met: `ObjectSchema.create` throws a `ZodError` at module load with `invalid_format` at `fields.FIELD.options.N.value` and the message `System identifier must be lowercase, starting with a letter, and may contain letters, numbers, underscores, or dots`, and the object save door answers `422 INVALID_METADATA` with the same finding. A dot is now kept (`'v1.2'` declares `v1.2`, which is a valid identifier).
21+
- **Unchanged:** `Field.select(['In Progress', 'Closed Won'])` still declares `in_progress` and `closed_won`. An explicit option's value is still lower-cased. `SelectOptionSchema.value` accepts and refuses exactly what it did before.
22+
- **The fix:** write such an option explicitly, with the value you will store: `Field.select({ options: [{ label: 'no-overlay', value: 'no_overlay' }] })`. If rows already hold the hyphenated spelling, the column is not a select: declare it as `Field.text({ maxLength: 255 })`.
23+
24+
`@objectstack/metadata-core`: `sys_metadata_audit.lock_state` was the one in-tree column relying on the strip. It declared the options `none`, `nooverlay`, `nodelete` and `full`, while its writer stores the `MetadataLock` spelling (`none`, `no-overlay`, `no-delete`, `full`), so no `no-overlay` or `no-delete` row ever matched a declared option. It is now `Field.text({ maxLength: 255 })`, read-only as before. `GET /api/v1/meta/object/sys_metadata_audit` serves `lock_state` as `type: 'text'` with `maxLength: 255` and no `options`. No stored row is rewritten, the writer and reader are unchanged, and an existing `varchar(255)` column reports no schema drift on postgres, mysql or sqlite.
25+
26+
`@objectstack/platform-objects`: the translation bundles drop the four `sys_metadata_audit.lock_state` option labels (`none`, `nooverlay`, `nodelete`, `full`), which the column no longer declares. The field label stays translated.
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* #25013 — `sys_metadata_audit.lock_state` declares a column that holds the
5+
* value its writer stores.
6+
*
7+
* The writer (`metadata-protocol` `protocol.ts`, `lock_state: entry.lockState
8+
* ?? 'none'`) stores a `MetadataLock` as spelled — `no-overlay`, `no-delete`.
9+
* The column used to be `Field.select([...])`, whose shorthand stripped the
10+
* hyphen and declared `nooverlay` / `nodelete`, values no row ever held. A
11+
* select option value is a system identifier, which holds no hyphen, so the
12+
* column is text, and the closed set stays with `MetadataLockSchema`.
13+
*/
14+
import { describe, expect, it } from 'vitest';
15+
import { ObjectSchema, SelectOptionSchema } from '@objectstack/spec/data';
16+
import { MetadataLockSchema } from '@objectstack/spec/kernel';
17+
import { SysMetadataAuditObject } from './sys-metadata-audit.object.js';
18+
19+
describe('sys_metadata_audit.lock_state (#25013)', () => {
20+
const field = SysMetadataAuditObject.fields.lock_state as Record<string, unknown>;
21+
22+
it('is a text column with no option list of its own, as wide as the select column was', () => {
23+
expect(field.type).toBe('text');
24+
expect(field).not.toHaveProperty('options');
25+
// The `varchar(255)` a select column is created as: an existing database
26+
// reports no drift (no `maxLength` would read as varchar → text).
27+
expect(field.maxLength).toBe(255);
28+
expect(field.readonly).toBe(true);
29+
});
30+
31+
it('the writer stores values a select option could not declare — which is why it is not a select', () => {
32+
const undeclarable = MetadataLockSchema.options.filter(
33+
(lock) => !SelectOptionSchema.safeParse({ label: lock, value: lock }).success,
34+
);
35+
expect(undeclarable).toEqual(['no-overlay', 'no-delete']);
36+
});
37+
38+
it('the object still parses as a whole', () => {
39+
expect(ObjectSchema.safeParse(SysMetadataAuditObject).success).toBe(true);
40+
});
41+
});

‎packages/metadata-core/src/objects/sys-metadata-audit.object.ts‎

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -186,11 +186,23 @@ export const SysMetadataAuditObject = ObjectSchema.create({
186186
* item carried no `_lock`). Captured even on `allowed` rows so
187187
* later compliance queries can see "what was the lock state when
188188
* this write succeeded".
189+
*
190+
* A text column, not a select: the writer stores the `MetadataLock`
191+
* spelling as is (`none` | `no-overlay` | `no-delete` | `full`), and a
192+
* select option value is a system identifier, which holds no hyphen. The
193+
* `Field.select([...])` shorthand used to strip the hyphen, declaring
194+
* `nooverlay` / `nodelete` — values no row ever held (#25013). Like `code`
195+
* above, the closed set lives with its writer (`MetadataLockSchema`).
196+
* `maxLength: 255` is the `varchar(255)` the select column was created
197+
* as, so an existing database reports no drift: measured with
198+
* `diffManagedTable` on postgres / mysql / sqlite, no `maxLength` reads as a
199+
* varchar → text type change and a narrower one as a destructive narrowing.
189200
*/
190-
lock_state: Field.select(['none', 'no-overlay', 'no-delete', 'full'], {
201+
lock_state: Field.text({
191202
label: 'Lock State',
192203
required: false,
193204
readonly: true,
205+
maxLength: 255,
194206
}),
195207

196208
/** True when the write succeeded by bypassing a lock (Phase 3). */
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* #25013 — a `Field.select` shorthand string that is no system identifier is
5+
* refused at the object save door, by the refusal an explicit option value
6+
* already meets.
7+
*
8+
* The shorthand (`packages/spec` `selectWithOptions`) used to delete every
9+
* character outside `[a-z0-9_]`, so `Field.select(['no-overlay'])` declared
10+
* the value `nooverlay` — a different value than the one written, accepted
11+
* without a word. It now only lower-cases and joins whitespace with `_`, so
12+
* the written value reaches `SelectOptionSchema.value` and `saveMetaItem`
13+
* answers `422 INVALID_METADATA` naming the option slot, exactly as for
14+
* `{ label: 'no-overlay', value: 'no-overlay' }`.
15+
*/
16+
import { describe, expect, it } from 'vitest';
17+
import { assertEngineDeleteDispatch, assertEngineUpdateDispatch, assertEngineFindOnePredicate, type EngineFindOneQueryInput } from '@objectstack/metadata-core';
18+
import { Field } from '@objectstack/spec/data';
19+
import { ObjectStackProtocolImplementation } from './protocol.js';
20+
21+
/** The engine surface the repository write path touches (as the container-issue-descent harness). */
22+
function makeProtocol() {
23+
const rows = new Map<string, Record<string, unknown>>();
24+
let nextId = 0;
25+
const engine: any = {
26+
async findOne(object: string, query?: EngineFindOneQueryInput) {
27+
assertEngineFindOnePredicate(object, query); return null; },
28+
async find() { return []; },
29+
async insert(table: string, data: Record<string, unknown>) {
30+
nextId += 1;
31+
if (table === 'sys_metadata') rows.set(`${data.type}|${data.name}`, data);
32+
return { id: `r_${nextId}` };
33+
},
34+
async update(_t: string, data: Record<string, unknown>, opts?: Record<string, unknown>) {
35+
assertEngineUpdateDispatch(data, opts);
36+
return { id: null };
37+
},
38+
async delete(_t: string, opts?: Record<string, unknown>) {
39+
assertEngineDeleteDispatch(opts);
40+
return { deleted: 0 };
41+
},
42+
registry: { registerItem: () => {}, registerObject: () => {} },
43+
};
44+
const protocol: any = new ObjectStackProtocolImplementation(engine, () => new Map());
45+
return { protocol, rows };
46+
}
47+
48+
async function rejection(promise: Promise<unknown>): Promise<any> {
49+
try {
50+
await promise;
51+
} catch (err) {
52+
return err;
53+
}
54+
throw new Error('expected the save to be rejected, but it resolved');
55+
}
56+
57+
const objectWith = (status: unknown) => ({
58+
name: 'ticket',
59+
label: 'Ticket',
60+
sharingModel: 'private',
61+
fields: { status: { name: 'status', label: 'Status', ...(status as object) } },
62+
});
63+
64+
describe('#25013 the save door refuses a shorthand option that is no system identifier', () => {
65+
it('`Field.select([\'open\', \'no-overlay\'])` answers 422 INVALID_METADATA at the option slot, and nothing is stored', async () => {
66+
const { protocol, rows } = makeProtocol();
67+
68+
const err = await rejection(protocol.saveMetaItem({
69+
type: 'object', name: 'ticket', item: objectWith(Field.select(['open', 'no-overlay'])),
70+
}));
71+
72+
expect(err.code).toBe('INVALID_METADATA');
73+
expect(err.status).toBe(422);
74+
expect(err.issues).toEqual([
75+
expect.objectContaining({ path: 'fields.status.options.1.value', code: 'invalid_format' }),
76+
]);
77+
expect(rows.size).toBe(0);
78+
});
79+
80+
it('the explicit `{ value: \'no-overlay\' }` meets the identical refusal — one refusal set, not two', async () => {
81+
const shorthand = await rejection(makeProtocol().protocol.saveMetaItem({
82+
type: 'object', name: 'ticket', item: objectWith(Field.select(['open', 'no-overlay'])),
83+
}));
84+
const explicit = await rejection(makeProtocol().protocol.saveMetaItem({
85+
type: 'object', name: 'ticket',
86+
item: objectWith(Field.select({ options: ['open', { label: 'no-overlay', value: 'no-overlay' }] })),
87+
}));
88+
89+
expect([explicit.code, explicit.status]).toEqual(['INVALID_METADATA', 422]);
90+
expect(shorthand.issues).toEqual(explicit.issues);
91+
});
92+
93+
it('control: the documented transform still saves (`In Progress` → `in_progress`)', async () => {
94+
const { protocol, rows } = makeProtocol();
95+
96+
const result = await protocol.saveMetaItem({
97+
type: 'object', name: 'ticket', item: objectWith(Field.select(['Open', 'In Progress'])),
98+
});
99+
100+
expect(result.success).toBe(true);
101+
expect(rows.size).toBe(1);
102+
});
103+
});

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

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3245,13 +3245,7 @@ export const enObjects: NonNullable<TranslationData['objects']> = {
32453245
label: "Code"
32463246
},
32473247
lock_state: {
3248-
label: "Lock State",
3249-
options: {
3250-
none: "none",
3251-
nooverlay: "no-overlay",
3252-
nodelete: "no-delete",
3253-
full: "full"
3254-
}
3248+
label: "Lock State"
32553249
},
32563250
lock_overridden: {
32573251
label: "Lock Overridden"

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

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3245,13 +3245,7 @@ export const esESObjects: NonNullable<TranslationData['objects']> = {
32453245
label: "Código"
32463246
},
32473247
lock_state: {
3248-
label: "Estado de bloqueo",
3249-
options: {
3250-
none: "Ninguno",
3251-
nooverlay: "Sin superposición",
3252-
nodelete: "Sin eliminación",
3253-
full: "Bloqueo total"
3254-
}
3248+
label: "Estado de bloqueo"
32553249
},
32563250
lock_overridden: {
32573251
label: "Bloqueo anulado"

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

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3245,13 +3245,7 @@ export const jaJPObjects: NonNullable<TranslationData['objects']> = {
32453245
label: "コード"
32463246
},
32473247
lock_state: {
3248-
label: "ロック状態",
3249-
options: {
3250-
none: "なし",
3251-
nooverlay: "オーバーレイ禁止",
3252-
nodelete: "削除禁止",
3253-
full: "完全ロック"
3254-
}
3248+
label: "ロック状態"
32553249
},
32563250
lock_overridden: {
32573251
label: "ロックの上書き"

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

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3245,13 +3245,7 @@ export const zhCNObjects: NonNullable<TranslationData['objects']> = {
32453245
label: "代码"
32463246
},
32473247
lock_state: {
3248-
label: "锁定状态",
3249-
options: {
3250-
none: "无",
3251-
nooverlay: "禁止叠加",
3252-
nodelete: "禁止删除",
3253-
full: "完全锁定"
3254-
}
3248+
label: "锁定状态"
32553249
},
32563250
lock_overridden: {
32573251
label: "锁定已被覆盖"

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

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1485,6 +1485,49 @@ describe('Field Factory Helpers', () => {
14851485
expect(selectField.options[1]).toEqual({ label: 'Inactive', value: 'inactive' });
14861486
expect(selectField.options[2]).toEqual({ label: 'Pending', value: 'pending' });
14871487
});
1488+
1489+
// #25013 — the shorthand's only transforms are lower-casing and
1490+
// whitespace → `_`. It used to also DELETE every other character, so
1491+
// `'no-overlay'` was declared as `nooverlay`: a value nobody wrote.
1492+
describe('a shorthand string is kept as written beyond the documented transform', () => {
1493+
const issuesOf = (field: unknown) => {
1494+
const result = FieldSchema.safeParse({ name: 'status', ...(field as object) });
1495+
expect(result.success).toBe(false);
1496+
return (result as { error: z.ZodError }).error.issues;
1497+
};
1498+
1499+
it('keeps a hyphen, so the value is the one written', () => {
1500+
expect(Field.select(['no-overlay']).options).toEqual([{ label: 'no-overlay', value: 'no-overlay' }]);
1501+
expect(Field.select({ options: ['user-agent-based'] }).options)
1502+
.toEqual([{ label: 'user-agent-based', value: 'user-agent-based' }]);
1503+
});
1504+
1505+
it('meets the refusal an explicit `{ value: \'no-overlay\' }` meets — the same issue, the same path', () => {
1506+
const shorthand = issuesOf(Field.select(['open', 'no-overlay']));
1507+
const explicit = issuesOf(Field.select({ options: ['open', { label: 'no-overlay', value: 'no-overlay' }] }));
1508+
expect(shorthand).toEqual(explicit);
1509+
expect(shorthand).toHaveLength(1);
1510+
expect(shorthand[0]).toMatchObject({ code: 'invalid_format', path: ['options', 1, 'value'] });
1511+
});
1512+
1513+
it('refuses at module load when an object declares it through `ObjectSchema.create`', () => {
1514+
let thrown: unknown;
1515+
try {
1516+
ObjectSchema.create({ name: 'probe_lock', label: 'Probe', fields: { lock_state: Field.select(['none', 'no-overlay']) } });
1517+
} catch (e) {
1518+
thrown = e;
1519+
}
1520+
expect(thrown).toBeInstanceOf(z.ZodError);
1521+
expect((thrown as z.ZodError).issues.map((i) => [i.code, i.path.join('.')]))
1522+
.toEqual([['invalid_format', 'fields.lock_state.options.1.value']]);
1523+
});
1524+
1525+
it('still lower-cases and joins whitespace with `_`, and those values parse', () => {
1526+
const field = Field.select(['In Progress', 'Closed Won', 'v1.2']);
1527+
expect(field.options.map((o) => o.value)).toEqual(['in_progress', 'closed_won', 'v1.2']);
1528+
expect(FieldSchema.safeParse({ name: 'stage', ...field }).success).toBe(true);
1529+
});
1530+
});
14881531
});
14891532

14901533
describe('Lookup and Master-Detail Fields', () => {

‎packages/spec/src/data/field.zod.ts‎

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3050,13 +3050,12 @@ type UserFieldInput = Omit<FieldInput, 'reference'> & {
30503050

30513051
/** `Field.select` with inline options — the array or `{ options }` forms. */
30523052
function selectWithOptions(optionsOrConfig: Array<SelectOption | string> | Omit<FieldInput, 'options'> & { options: Array<SelectOption | string> }, config?: FieldInput) {
3053-
// Helper function to convert string to lowercase snake_case
3054-
const toSnakeCase = (str: string): string => {
3055-
return str
3056-
.toLowerCase()
3057-
.replace(/\s+/g, '_') // Replace spaces with underscores
3058-
.replace(/[^a-z0-9_]/g, ''); // Remove invalid characters (keeping underscores only)
3059-
};
3053+
// The shorthand's whole transform: lower-case, and whitespace runs → `_`.
3054+
// Every other character stays as written, so a string that is no system
3055+
// identifier (`'no-overlay'`, `'v2-beta'`) reaches `SelectOptionSchema.value`
3056+
// and is refused there, by the same rule an explicit `{ value: 'no-overlay' }`
3057+
// meets — never rewritten into a value the author did not write (#25013).
3058+
const toSnakeCase = (str: string): string => str.toLowerCase().replace(/\s+/g, '_');
30603059

30613060
// Support both old and new signatures:
30623061
// Old: Field.select(['a', 'b'], { label: 'X' })
@@ -3163,6 +3162,12 @@ export const Field = {
31633162
* Field.select(['In Progress', 'Closed Won'], { label: 'Status' })
31643163
* // Results in: [{ label: 'In Progress', value: 'in_progress' }, { label: 'Closed Won', value: 'closed_won' }]
31653164
*
3165+
* Lower-casing and whitespace → `_` are the shorthand's only transforms; any
3166+
* other character is kept, so `Field.select(['no-overlay'])` yields the value
3167+
* `no-overlay`, which `SelectOptionSchema.value` refuses (a system identifier
3168+
* holds letters, digits, `_` and `.`). Write such an option explicitly:
3169+
* `{ label: 'no-overlay', value: 'no_overlay' }`.
3170+
*
31663171
* @example Shared picklist — the options come from the named `picklist` item
31673172
* Field.select({ picklist: 'industry', label: 'Industry' })
31683173
* // Results in: { type: 'select', picklist: 'industry', label: 'Industry' } — no `options`

0 commit comments

Comments
 (0)