|
| 1 | +--- |
| 2 | +"@objectstack/spec": minor |
| 3 | +"@objectstack/driver-sql": minor |
| 4 | +--- |
| 5 | + |
| 6 | +fix(spec)!: `multiple: true` is refused on every type outside the multi-capable set, and driver-sql derives JSON-column storage from the spec predicate (#17469) |
| 7 | + |
| 8 | +<!-- adr-0087: registered field-multiple-non-capable-type-refused --> |
| 9 | + |
| 10 | +**BREAKING** in the accept-set sense, landing in the launch window as `minor` |
| 11 | +(the lockstep convention: `major` is refused by `check-changeset-no-major`, and |
| 12 | +breaking-ness is carried by this banner plus the ADR-0087 disposition). |
| 13 | + |
| 14 | +Two definitions of "multi-valued" disagreed, and the user saw the disagreement as |
| 15 | +a `400`. |
| 16 | + |
| 17 | +- `FieldSchema` accepted `multiple: true` on **any** type. |
| 18 | +- `@objectstack/driver-sql`'s `isJsonField` read the flag raw — |
| 19 | + `JSON_COLUMN_TYPES.has(type) || !!field.multiple` — and built a **JSON array |
| 20 | + column** for it. |
| 21 | +- `isMultiValueField` — the published spec predicate consumers shape queries from |
| 22 | + — answered **"not multi-value"** for that same field, because `master_detail` / |
| 23 | + `tree` / `text` are outside `MULTI_CAPABLE_TYPES`. |
| 24 | + |
| 25 | +So a related list composed `=` against a JSON array column, and the driver refused |
| 26 | +the equality family there with a `400`. |
| 27 | + |
| 28 | +In business terms: `multiple` means "this cell holds several values at once", and |
| 29 | +that has meaning only on multi-select, multi-record / multi-user and multi-file |
| 30 | +fields — exactly what the spec already declares. A child record with several |
| 31 | +masters, a tree node with several parents, or a text box holding several texts has |
| 32 | +no meaning on any mainstream platform. The declaration was accepted silently, the |
| 33 | +UI rendered a single value, the database built a JSON array column, and the |
| 34 | +related list answered the user a 400. |
| 35 | + |
| 36 | +FROM → TO, for metadata that used to parse and now fails: |
| 37 | + |
| 38 | +```ts |
| 39 | +// FROM — parsed, stored a JSON array, rendered single, answered `=` with 400 |
| 40 | +{ type: 'text', label: 'Aliases', multiple: true } |
| 41 | +{ type: 'master_detail', label: 'Parents', reference: 'account', multiple: true } |
| 42 | +{ type: 'tree', label: 'Parents', reference: 'category', multiple: true } |
| 43 | + |
| 44 | +// TO — pick the type that actually holds several values… |
| 45 | +{ type: 'tags', label: 'Aliases' } // several free-form strings |
| 46 | +{ type: 'lookup', label: 'Parents', reference: 'account', multiple: true } // several related records |
| 47 | + |
| 48 | +// …or drop the key, if the cell really holds one value. |
| 49 | +{ type: 'text', label: 'Alias' } |
| 50 | +{ type: 'master_detail', label: 'Parent', reference: 'account' } |
| 51 | +``` |
| 52 | + |
| 53 | +The refusal names the field, its type and the alternative, on the `multiple` path. |
| 54 | +`radio` keeps its own narrower 2026-08-22 message (#11437); the two never |
| 55 | +double-fire. |
| 56 | + |
| 57 | +**`MULTI_CAPABLE_TYPES` and `isMultiValueField` are untouched**, deliberately: a |
| 58 | +field that was already multi-valued by that predicate keeps its declaration, its |
| 59 | +storage and its read path byte-identically. What moved is which declarations can |
| 60 | +be newly authored, plus the storage decision for the shapes that are now refused. |
| 61 | + |
| 62 | +**Storage change (`@objectstack/driver-sql`)**: every site that asked |
| 63 | +`field.multiple` the question "is this value multi-valued" now asks |
| 64 | +`isMultiValueField` — **eighteen expressions across two files**, not one. The |
| 65 | +file's own header already called `JSON_COLUMN_TYPES` membership "owned by |
| 66 | +`@objectstack/spec`"; that sentence is now true for the `multiple` half too. |
| 67 | + |
| 68 | +- `sql-driver.ts` — the DDL writer (`createColumn`'s multi-value short-circuit), |
| 69 | + the read-side deserializer (`isJsonField`, both limbs), the `varchar` width |
| 70 | + mirror (`varcharColumnChars`), the cross-field comparison class |
| 71 | + (`crossFieldComparisonClass`), the four scalar registries filled by BOTH |
| 72 | + `registerObjectMetadata` and `registerExternalObject` (`mediaFields`, |
| 73 | + `booleanFields`, `numericFields`, `numericValueFields`), and the two MySQL |
| 74 | + temporal-widening candidate sets. |
| 75 | +- `schema-drift.ts` — the differ's `fieldHasColumn`, its `declaresJsonColumn` |
| 76 | + disjunct and its `declaresArray` test, which #15771 bound to the writer's |
| 77 | + predicate and which a pin test holds equal to it. |
| 78 | + |
| 79 | +Only one of those was named in the ruling; aligning it and leaving seventeen |
| 80 | +would have re-opened #11535 in reverse — the DDL writing a JSON column that the |
| 81 | +read-side deserializer no longer recognises. A column whose field is multi-valued |
| 82 | +by the spec predicate behaves exactly as before; the shapes that change are the |
| 83 | +ones the schema now refuses at the entrance. |
| 84 | + |
| 85 | +⛔ Three `field.multiple` reads are deliberately NOT aligned: the three that |
| 86 | +interpolate `', multiple'` into an `uncompilableFieldReferenceError` message. |
| 87 | +They echo what the author DECLARED back to them; they do not ask whether the |
| 88 | +value is multi-valued (the verdict there comes from `crossFieldComparisonClass`, |
| 89 | +which is aligned). |
| 90 | + |
| 91 | +⚠️ **Two consequences worth reading before you upgrade.** |
| 92 | + |
| 93 | +1. A **stored** field carrying `multiple: true` on a non-capable type has no |
| 94 | + lossless conversion — its column was physically built as a JSON array. The |
| 95 | + ADR-0087 semantic entry `field-multiple-non-capable-type-refused` emits the |
| 96 | + structured TODO naming the object, field and type; migrating the data is the |
| 97 | + author's judgment call, and the entry states how to prove it. |
| 98 | +2. `isMultiValueField` reads the **authorable** `FieldType` vocabulary. A driver |
| 99 | + -internal column-type alias (`string` / `integer` / `int` / `float` — the |
| 100 | + introspected-column spellings) is not a `FieldType`, so a hand-declared |
| 101 | + external object that puts `multiple: true` on one of those no longer gets a |
| 102 | + JSON column. Declare such a column as `object` or `array` (both are |
| 103 | + `JSON_COLUMN_TYPES` members and unchanged), or as the authorable type it |
| 104 | + really is. |
| 105 | +3. `multiple: true` on `boolean` / `toggle` / `number` / `currency` / `percent` / |
| 106 | + `date` / `datetime` / `time` **ceases to be a supported shape end to end**, as |
| 107 | + a consequence of the entrance refusal above. Such a column is no longer a JSON |
| 108 | + column, so it is no longer excluded from the scalar read-coercion registries |
| 109 | + and the declared-type text-operator gate (`isNonTextColumn`) applies to it: a |
| 110 | + `$contains` against one answers the declared no-match rather than a JSON |
| 111 | + membership test. Stored data in that shape is the ADR-0087 entry's subject. |
0 commit comments