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
12 changes: 12 additions & 0 deletions .changeset/21509-verify-select-multiple.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
"@objectstack/verify": patch
---

`os verify` writes each derived sample in the shape the engine stores it: a `select` declared `multiple: true` is written as a list and compared as a set

Clause-②: no

- The CRUD round-trip derivation now asks `@objectstack/spec`'s `isMultiValueField` whether a field is multi-valued, the same predicate the engine stores by. Before, the `select` / `radio` sample was one scalar option code compared `equal` whatever the field declared, so a multi-valued `select` read back as a one-element list and was reported as a fidelity gap the engine does not have. The shipped `examples/app-todo` (`todo_task.tags`) failed `os verify` with exit 1 on exactly that, and now passes.
- A single-valued `select` or `radio` keeps its scalar sample and its `equal` comparison. `multiselect` and `checkboxes` are unchanged.
- A relational field's `multiple` is answered by the same predicate. A `lookup` declared `multiple: true` still receives a list of ids. A `master_detail` or `tree` field carrying `multiple: true` now receives one id, which is how the engine stores those types. The spec already refuses `multiple` on those types at parse, so only an unparsed config could reach this.
- No export, type or accept-set change.
86 changes: 86 additions & 0 deletions packages/verify/src/derive.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

import { describe, it, expect } from 'vitest';
import { Field, FieldType, isMultiValueField } from '@objectstack/spec/data';
// `.js` extension, deliberately: under `moduleResolution: NodeNext` a relative
// import without it does not RESOLVE, so every symbol it names becomes `any` —
// and the type-layer ratchet reads this file (`TEST_DEBT`, via the package's
Expand Down Expand Up @@ -221,3 +222,88 @@ describe('deriveCrudCases — an UNREADABLE `reference` carrier is refused (#185
expect(blocked?.blocked).not.toMatch(/rejected alias/i);
});
});

/**
* [#21509] A sample is written in the shape the field is STORED in, and that
* shape has exactly one answer: `@objectstack/spec`'s `isMultiValueField`, the
* predicate the engine, `driver-sql` and `os generate migration` all store by.
*
* The `select` / `radio` arm used to write one scalar option code and compare
* it `equal` whatever the field declared. A `select` declared `multiple: true`
* is stored and served as a LIST, so it read back `[code]` and `os verify`
* reported a fidelity gap the engine does not have — the shipped
* `examples/app-todo` (`todo_task.tags`) failed on exactly that.
*/
describe('deriveCrudCases — a sample takes the shape the field is STORED in (#21509)', () => {
const OPTIONS = [
{ label: 'Important', value: 'important' },
{ label: 'Quick Win', value: 'quick_win' },
];
const caseFor = (field: Record<string, unknown>) =>
deriveCrudCases({
objects: [
{ name: 'company', fields: { title: { type: 'text' } } },
{ name: 'probe', fields: { f: field } },
],
}).find((x) => x.object === 'probe');

it('a `select` declared `multiple: true` is written as a list and compared as a set', () => {
// Built with the spec's own builder — the way `examples/app-todo` authors
// `todo_task.tags` — rather than a hand-written literal.
const c = caseFor(Field.select({ label: 'Tags', multiple: true, options: OPTIONS }));
expect(c?.body?.f).toEqual(['important']);
expect(c?.asserts).toEqual([{ field: 'f', type: 'select', value: ['important'], kind: 'set' }]);
});

it.each([
['omitted', {}],
['false', { multiple: false }],
])('control: a single-valued `select` (`multiple` %s) keeps one scalar code and `equal`', (_label, flag) => {
const c = caseFor(Field.select({ label: 'Priority', options: OPTIONS, ...flag }));
expect(c?.body?.f).toBe('important');
expect(c?.asserts).toEqual([{ field: 'f', type: 'select', value: 'important', kind: 'equal' }]);
});

/**
* The claim's enumeration, made mechanical: over EVERY `FieldType` the spec
* declares, with the flag and without it, whatever the derivation writes is
* a list exactly when `isMultiValueField` says the field is stored as one —
* scalar samples and relational refs alike. The loop is driven by the spec's
* own enum, so a type added there is judged here without an edit.
*/
it('over every FieldType × `multiple`, the written shape IS `isMultiValueField`', () => {
const judged: string[] = [];
const listWritten = new Set<string>();
for (const type of FieldType.options) {
for (const multiple of [true, false]) {
const at = `${type} multiple=${multiple}`;
const stored = isMultiValueField({ type, multiple });
const c = caseFor({ type, multiple, options: OPTIONS, reference: 'company' });
expect(c?.blocked, at).toBeFalsy();

const ref = c?.relationalRefs?.find((r) => r.field === 'f');
if (ref) {
expect(ref.multiple, at).toBe(stored);
} else if (c?.body && 'f' in c.body) {
expect(Array.isArray(c.body.f), at).toBe(stored);
const a = c.asserts?.find((x) => x.field === 'f');
if (a) expect(a.kind === 'set', at).toBe(stored);
} else {
continue; // not written at all (computed / structured / media / no synth) — no shape to judge
}
judged.push(at);
if (stored) listWritten.add(type);
}
}
// Non-vacuity: the loop must actually have judged the multi-valued writers,
// or every assertion above could pass over a derivation that writes nothing.
expect([...listWritten]).toEqual(
expect.arrayContaining(['select', 'radio', 'multiselect', 'checkboxes', 'tags', 'lookup']),
);
// …and the single-valued side: a flag the predicate does not honour (a
// `text`, a `master_detail`) is judged as the scalar it is stored as.
expect(judged).toEqual(
expect.arrayContaining(['select multiple=false', 'text multiple=true', 'master_detail multiple=true']),
);
});
});
40 changes: 32 additions & 8 deletions packages/verify/src/derive.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
// is reported `blocked` with a precise reason — the gate stays honest.


import { referenceCarrierOf } from '@objectstack/spec/data';
import { isMultiValueField, referenceCarrierOf } from '@objectstack/spec/data';
import { declaredCollection } from './artifact-collections.js';

const COMPUTED = new Set(['formula', 'summary', 'autonumber', 'rollup', 'vector']);
Expand Down Expand Up @@ -65,6 +65,29 @@ function clampNum(f: any, fallback: number): number {
return v;
}

/**
* [#21509] Is this field's value MULTI-VALUED — the one question, asked of the
* one predicate `@objectstack/spec` publishes (`isMultiValueField`), and the
* single seam every site in this file goes through: the option arm of
* {@link synth} and the relational ref in {@link deriveCrudCases}.
*
* The engine stores by that predicate (objectql's `declaredMultiValued`, and
* `driver-sql` and `os generate migration` ask it too), so a sample shaped by
* any other answer is written in a form the engine never stores and reads back
* as a fidelity gap the engine does not have. The option arm used to answer
* per TYPE: a `select` declared `multiple: true` was written as one scalar code
* and compared `equal`, then read back as `[code]`.
*
* ⛔ The predicate is called, never re-spelled. A raw `f.multiple` read, or
* `MULTI_CAPABLE_TYPES` / `MULTI_OPTION_TYPES` membership written out here,
* would be a second answer to a question the spec answers once — a raw read
* says `true` on types the predicate stores as a scalar (`master_detail`,
* `tree`, `text` …), and `false` on the inherently-multi option types.
*/
function declaredMultiValued(type: string, f: any): boolean {
return isMultiValueField({ type, multiple: f?.multiple === true });
}

/** Synthesize a valid value for a field type, or null if not synthesizable. */
function synth(type: string, f: any): { value: unknown; kind: AssertKind } | null {
switch (type) {
Expand All @@ -85,13 +108,14 @@ function synth(type: string, f: any): { value: unknown; kind: AssertKind } | nul
case 'datetime': return { value: '2024-03-15T08:30:00.000Z', kind: 'equal' };
case 'time': return { value: '14:30:00', kind: 'equal' };
case 'json': return { value: { sample: true }, kind: 'equal' };
case 'select': case 'radio': {
const opt = f.options?.[0]?.value;
return opt != null ? { value: opt, kind: 'equal' } : null;
}
case 'multiselect': case 'checkboxes': {
// One declared option code, in the shape the field is STORED in: a list,
// compared as a set, exactly when {@link declaredMultiValued} says so —
// always for `multiselect` / `checkboxes`, and for a `select` / `radio`
// declared `multiple: true`; otherwise the scalar code, compared `equal`.
case 'select': case 'radio': case 'multiselect': case 'checkboxes': {
const opt = f.options?.[0]?.value;
return opt != null ? { value: [opt], kind: 'set' } : null;
if (opt == null) return null;
return declaredMultiValued(type, f) ? { value: [opt], kind: 'set' } : { value: opt, kind: 'equal' };
}
case 'tags': return { value: ['alpha', 'beta'], kind: 'set' };
// Opaque-on-read: write a value but don't assert a round-trip (hashed/encrypted).
Expand Down Expand Up @@ -258,7 +282,7 @@ export function deriveCrudCases(config: any): CrudCase[] {
d.skippedFields.push({ name, type, reason: `relation-target-external:${target}` });
continue;
}
d.relationalRefs.push({ field: name, target, required: isRequired, multiple: !!(f as any)?.multiple });
d.relationalRefs.push({ field: name, target, required: isRequired, multiple: declaredMultiValued(type, f) });
if (isRequired) d.requiredTargets.push(target);
continue;
}
Expand Down
Loading