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
26 changes: 26 additions & 0 deletions .changeset/25013-select-shorthand-keeps-written-value.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
---
'@objectstack/spec': minor
'@objectstack/metadata-core': minor
'@objectstack/platform-objects': patch
---

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

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

<!-- adr-0087: registered field-select-shorthand-keeps-written-value -->

**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.

`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`.

- **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.
- **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).
- **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.
- **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 })`.

`@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.

`@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.
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* #25013 — `sys_metadata_audit.lock_state` declares a column that holds the
* value its writer stores.
*
* The writer (`metadata-protocol` `protocol.ts`, `lock_state: entry.lockState
* ?? 'none'`) stores a `MetadataLock` as spelled — `no-overlay`, `no-delete`.
* The column used to be `Field.select([...])`, whose shorthand stripped the
* hyphen and declared `nooverlay` / `nodelete`, values no row ever held. A
* select option value is a system identifier, which holds no hyphen, so the
* column is text, and the closed set stays with `MetadataLockSchema`.
*/
import { describe, expect, it } from 'vitest';
import { ObjectSchema, SelectOptionSchema } from '@objectstack/spec/data';
import { MetadataLockSchema } from '@objectstack/spec/kernel';
import { SysMetadataAuditObject } from './sys-metadata-audit.object.js';

describe('sys_metadata_audit.lock_state (#25013)', () => {
const field = SysMetadataAuditObject.fields.lock_state as Record<string, unknown>;

it('is a text column with no option list of its own, as wide as the select column was', () => {
expect(field.type).toBe('text');
expect(field).not.toHaveProperty('options');
// The `varchar(255)` a select column is created as: an existing database
// reports no drift (no `maxLength` would read as varchar → text).
expect(field.maxLength).toBe(255);
expect(field.readonly).toBe(true);
});

it('the writer stores values a select option could not declare — which is why it is not a select', () => {
const undeclarable = MetadataLockSchema.options.filter(
(lock) => !SelectOptionSchema.safeParse({ label: lock, value: lock }).success,
);
expect(undeclarable).toEqual(['no-overlay', 'no-delete']);
});

it('the object still parses as a whole', () => {
expect(ObjectSchema.safeParse(SysMetadataAuditObject).success).toBe(true);
});
});
14 changes: 13 additions & 1 deletion packages/metadata-core/src/objects/sys-metadata-audit.object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -186,11 +186,23 @@ export const SysMetadataAuditObject = ObjectSchema.create({
* item carried no `_lock`). Captured even on `allowed` rows so
* later compliance queries can see "what was the lock state when
* this write succeeded".
*
* A text column, not a select: the writer stores the `MetadataLock`
* spelling as is (`none` | `no-overlay` | `no-delete` | `full`), and a
* select option value is a system identifier, which holds no hyphen. The
* `Field.select([...])` shorthand used to strip the hyphen, declaring
* `nooverlay` / `nodelete` — values no row ever held (#25013). Like `code`
* above, the closed set lives with its writer (`MetadataLockSchema`).
* `maxLength: 255` is the `varchar(255)` the select column was created
* as, so an existing database reports no drift: measured with
* `diffManagedTable` on postgres / mysql / sqlite, no `maxLength` reads as a
* varchar → text type change and a narrower one as a destructive narrowing.
*/
lock_state: Field.select(['none', 'no-overlay', 'no-delete', 'full'], {
lock_state: Field.text({
label: 'Lock State',
required: false,
readonly: true,
maxLength: 255,
}),

/** True when the write succeeded by bypassing a lock (Phase 3). */
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* #25013 — a `Field.select` shorthand string that is no system identifier is
* refused at the object save door, by the refusal an explicit option value
* already meets.
*
* The shorthand (`packages/spec` `selectWithOptions`) used to delete every
* character outside `[a-z0-9_]`, so `Field.select(['no-overlay'])` declared
* the value `nooverlay` — a different value than the one written, accepted
* without a word. It now only lower-cases and joins whitespace with `_`, so
* the written value reaches `SelectOptionSchema.value` and `saveMetaItem`
* answers `422 INVALID_METADATA` naming the option slot, exactly as for
* `{ label: 'no-overlay', value: 'no-overlay' }`.
*/
import { describe, expect, it } from 'vitest';
import { assertEngineDeleteDispatch, assertEngineUpdateDispatch, assertEngineFindOnePredicate, type EngineFindOneQueryInput } from '@objectstack/metadata-core';
import { Field } from '@objectstack/spec/data';
import { ObjectStackProtocolImplementation } from './protocol.js';

/** The engine surface the repository write path touches (as the container-issue-descent harness). */
function makeProtocol() {
const rows = new Map<string, Record<string, unknown>>();
let nextId = 0;
const engine: any = {
async findOne(object: string, query?: EngineFindOneQueryInput) {
assertEngineFindOnePredicate(object, query); return null; },
async find() { return []; },
async insert(table: string, data: Record<string, unknown>) {
nextId += 1;
if (table === 'sys_metadata') rows.set(`${data.type}|${data.name}`, data);
return { id: `r_${nextId}` };
},
async update(_t: string, data: Record<string, unknown>, opts?: Record<string, unknown>) {
assertEngineUpdateDispatch(data, opts);
return { id: null };
},
async delete(_t: string, opts?: Record<string, unknown>) {
assertEngineDeleteDispatch(opts);
return { deleted: 0 };
},
registry: { registerItem: () => {}, registerObject: () => {} },
};
const protocol: any = new ObjectStackProtocolImplementation(engine, () => new Map());
return { protocol, rows };
}

async function rejection(promise: Promise<unknown>): Promise<any> {
try {
await promise;
} catch (err) {
return err;
}
throw new Error('expected the save to be rejected, but it resolved');
}

const objectWith = (status: unknown) => ({
name: 'ticket',
label: 'Ticket',
sharingModel: 'private',
fields: { status: { name: 'status', label: 'Status', ...(status as object) } },
});

describe('#25013 the save door refuses a shorthand option that is no system identifier', () => {
it('`Field.select([\'open\', \'no-overlay\'])` answers 422 INVALID_METADATA at the option slot, and nothing is stored', async () => {
const { protocol, rows } = makeProtocol();

const err = await rejection(protocol.saveMetaItem({
type: 'object', name: 'ticket', item: objectWith(Field.select(['open', 'no-overlay'])),
}));

expect(err.code).toBe('INVALID_METADATA');
expect(err.status).toBe(422);
expect(err.issues).toEqual([
expect.objectContaining({ path: 'fields.status.options.1.value', code: 'invalid_format' }),
]);
expect(rows.size).toBe(0);
});

it('the explicit `{ value: \'no-overlay\' }` meets the identical refusal — one refusal set, not two', async () => {
const shorthand = await rejection(makeProtocol().protocol.saveMetaItem({
type: 'object', name: 'ticket', item: objectWith(Field.select(['open', 'no-overlay'])),
}));
const explicit = await rejection(makeProtocol().protocol.saveMetaItem({
type: 'object', name: 'ticket',
item: objectWith(Field.select({ options: ['open', { label: 'no-overlay', value: 'no-overlay' }] })),
}));

expect([explicit.code, explicit.status]).toEqual(['INVALID_METADATA', 422]);
expect(shorthand.issues).toEqual(explicit.issues);
});

it('control: the documented transform still saves (`In Progress` → `in_progress`)', async () => {
const { protocol, rows } = makeProtocol();

const result = await protocol.saveMetaItem({
type: 'object', name: 'ticket', item: objectWith(Field.select(['Open', 'In Progress'])),
});

expect(result.success).toBe(true);
expect(rows.size).toBe(1);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -3245,13 +3245,7 @@ export const enObjects: NonNullable<TranslationData['objects']> = {
label: "Code"
},
lock_state: {
label: "Lock State",
options: {
none: "none",
nooverlay: "no-overlay",
nodelete: "no-delete",
full: "full"
}
label: "Lock State"
},
lock_overridden: {
label: "Lock Overridden"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3245,13 +3245,7 @@ export const esESObjects: NonNullable<TranslationData['objects']> = {
label: "Código"
},
lock_state: {
label: "Estado de bloqueo",
options: {
none: "Ninguno",
nooverlay: "Sin superposición",
nodelete: "Sin eliminación",
full: "Bloqueo total"
}
label: "Estado de bloqueo"
},
lock_overridden: {
label: "Bloqueo anulado"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3245,13 +3245,7 @@ export const jaJPObjects: NonNullable<TranslationData['objects']> = {
label: "コード"
},
lock_state: {
label: "ロック状態",
options: {
none: "なし",
nooverlay: "オーバーレイ禁止",
nodelete: "削除禁止",
full: "完全ロック"
}
label: "ロック状態"
},
lock_overridden: {
label: "ロックの上書き"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3245,13 +3245,7 @@ export const zhCNObjects: NonNullable<TranslationData['objects']> = {
label: "代码"
},
lock_state: {
label: "锁定状态",
options: {
none: "无",
nooverlay: "禁止叠加",
nodelete: "禁止删除",
full: "完全锁定"
}
label: "锁定状态"
},
lock_overridden: {
label: "锁定已被覆盖"
Expand Down
43 changes: 43 additions & 0 deletions packages/spec/src/data/field.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1485,6 +1485,49 @@ describe('Field Factory Helpers', () => {
expect(selectField.options[1]).toEqual({ label: 'Inactive', value: 'inactive' });
expect(selectField.options[2]).toEqual({ label: 'Pending', value: 'pending' });
});

// #25013 — the shorthand's only transforms are lower-casing and
// whitespace → `_`. It used to also DELETE every other character, so
// `'no-overlay'` was declared as `nooverlay`: a value nobody wrote.
describe('a shorthand string is kept as written beyond the documented transform', () => {
const issuesOf = (field: unknown) => {
const result = FieldSchema.safeParse({ name: 'status', ...(field as object) });
expect(result.success).toBe(false);
return (result as { error: z.ZodError }).error.issues;
};

it('keeps a hyphen, so the value is the one written', () => {
expect(Field.select(['no-overlay']).options).toEqual([{ label: 'no-overlay', value: 'no-overlay' }]);
expect(Field.select({ options: ['user-agent-based'] }).options)
.toEqual([{ label: 'user-agent-based', value: 'user-agent-based' }]);
});

it('meets the refusal an explicit `{ value: \'no-overlay\' }` meets — the same issue, the same path', () => {
const shorthand = issuesOf(Field.select(['open', 'no-overlay']));
const explicit = issuesOf(Field.select({ options: ['open', { label: 'no-overlay', value: 'no-overlay' }] }));
expect(shorthand).toEqual(explicit);
expect(shorthand).toHaveLength(1);
expect(shorthand[0]).toMatchObject({ code: 'invalid_format', path: ['options', 1, 'value'] });
});

it('refuses at module load when an object declares it through `ObjectSchema.create`', () => {
let thrown: unknown;
try {
ObjectSchema.create({ name: 'probe_lock', label: 'Probe', fields: { lock_state: Field.select(['none', 'no-overlay']) } });
} catch (e) {
thrown = e;
}
expect(thrown).toBeInstanceOf(z.ZodError);
expect((thrown as z.ZodError).issues.map((i) => [i.code, i.path.join('.')]))
.toEqual([['invalid_format', 'fields.lock_state.options.1.value']]);
});

it('still lower-cases and joins whitespace with `_`, and those values parse', () => {
const field = Field.select(['In Progress', 'Closed Won', 'v1.2']);
expect(field.options.map((o) => o.value)).toEqual(['in_progress', 'closed_won', 'v1.2']);
expect(FieldSchema.safeParse({ name: 'stage', ...field }).success).toBe(true);
});
});
});

describe('Lookup and Master-Detail Fields', () => {
Expand Down
19 changes: 12 additions & 7 deletions packages/spec/src/data/field.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3050,13 +3050,12 @@ type UserFieldInput = Omit<FieldInput, 'reference'> & {

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

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