From 0b324aef4bb6d5e56ee2b8b47cb49616c4cfca1c Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 14 Sep 2026 10:29:11 +0000 Subject: [PATCH 1/6] fix(spec)!: refuse `multiple: true` on non-multi-capable field types; driver-sql derives JSON storage from the spec predicate Maintainer ruling 2026-09-13 (decision batch #128 item 5, option 1'): one definition of "multi-valued". `FieldSchema` refuses an authored `multiple: true` on any type outside MULTI_CAPABLE_TYPES union MULTI_OPTION_TYPES, and driver-sql's `isJsonField` asks `isMultiValueField` instead of reading `field.multiple` raw. `MULTI_CAPABLE_TYPES` and `isMultiValueField` are untouched. Claude-Session: https://claude.ai/code/session_01KB5PFtxuy1x3dcR5gxudx6 Co-authored-by: Claude --- ...17469-multiple-non-capable-type-refused.md | 82 +++++++++++ packages/drivers/driver-sql/src/sql-driver.ts | 33 ++++- packages/spec/src/data/field.test.ts | 132 +++++++++++++++++- packages/spec/src/data/field.zod.ts | 84 ++++++++++- ...field-multiple-non-capable-type-refused.ts | 44 ++++++ 5 files changed, 367 insertions(+), 8 deletions(-) create mode 100644 .changeset/17469-multiple-non-capable-type-refused.md create mode 100644 packages/spec/src/migrations/entries/semantic/18.field-multiple-non-capable-type-refused.ts diff --git a/.changeset/17469-multiple-non-capable-type-refused.md b/.changeset/17469-multiple-non-capable-type-refused.md new file mode 100644 index 0000000000..43bf6701cb --- /dev/null +++ b/.changeset/17469-multiple-non-capable-type-refused.md @@ -0,0 +1,82 @@ +--- +"@objectstack/spec": minor +"@objectstack/driver-sql": minor +--- + +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) + + + +**BREAKING** in the accept-set sense, landing in the launch window as `minor` +(the lockstep convention: `major` is refused by `check-changeset-no-major`, and +breaking-ness is carried by this banner plus the ADR-0087 disposition). + +Two definitions of "multi-valued" disagreed, and the user saw the disagreement as +a `400`. + +- `FieldSchema` accepted `multiple: true` on **any** type. +- `@objectstack/driver-sql`'s `isJsonField` read the flag raw — + `JSON_COLUMN_TYPES.has(type) || !!field.multiple` — and built a **JSON array + column** for it. +- `isMultiValueField` — the published spec predicate consumers shape queries from + — answered **"not multi-value"** for that same field, because `master_detail` / + `tree` / `text` are outside `MULTI_CAPABLE_TYPES`. + +So a related list composed `=` against a JSON array column, and the driver refused +the equality family there with a `400`. + +In business terms: `multiple` means "this cell holds several values at once", and +that has meaning only on multi-select, multi-record / multi-user and multi-file +fields — exactly what the spec already declares. A child record with several +masters, a tree node with several parents, or a text box holding several texts has +no meaning on any mainstream platform. The declaration was accepted silently, the +UI rendered a single value, the database built a JSON array column, and the +related list answered the user a 400. + +FROM → TO, for metadata that used to parse and now fails: + +```ts +// FROM — parsed, stored a JSON array, rendered single, answered `=` with 400 +{ type: 'text', label: 'Aliases', multiple: true } +{ type: 'master_detail', label: 'Parents', reference: 'account', multiple: true } +{ type: 'tree', label: 'Parents', reference: 'category', multiple: true } + +// TO — pick the type that actually holds several values… +{ type: 'tags', label: 'Aliases' } // several free-form strings +{ type: 'lookup', label: 'Parents', reference: 'account', multiple: true } // several related records + +// …or drop the key, if the cell really holds one value. +{ type: 'text', label: 'Alias' } +{ type: 'master_detail', label: 'Parent', reference: 'account' } +``` + +The refusal names the field, its type and the alternative, on the `multiple` path. +`radio` keeps its own narrower 2026-08-22 message (#11437); the two never +double-fire. + +**`MULTI_CAPABLE_TYPES` and `isMultiValueField` are untouched**, deliberately: a +field that was already multi-valued by that predicate keeps its declaration, its +storage and its read path byte-identically. What moved is which declarations can +be newly authored, plus the storage decision for the shapes that are now refused. + +**Storage change (`@objectstack/driver-sql`)**: `isJsonField` becomes +`JSON_COLUMN_TYPES.has(type) || isMultiValueField(field)`. The file's own header +already called `JSON_COLUMN_TYPES` membership "owned by `@objectstack/spec`"; that +sentence is now true for the `multiple` half too. A column whose field is +multi-valued by the spec predicate is a JSON column exactly as before; the shapes +that change are the ones the schema now refuses at the entrance. + +⚠️ **Two consequences worth reading before you upgrade.** + +1. A **stored** field carrying `multiple: true` on a non-capable type has no + lossless conversion — its column was physically built as a JSON array. The + ADR-0087 semantic entry `field-multiple-non-capable-type-refused` emits the + structured TODO naming the object, field and type; migrating the data is the + author's judgment call, and the entry states how to prove it. +2. `isMultiValueField` reads the **authorable** `FieldType` vocabulary. A driver + -internal column-type alias (`string` / `integer` / `int` / `float` — the + introspected-column spellings) is not a `FieldType`, so a hand-declared + external object that puts `multiple: true` on one of those no longer gets a + JSON column. Declare such a column as `object` or `array` (both are + `JSON_COLUMN_TYPES` members and unchanged), or as the authorable type it + really is. diff --git a/packages/drivers/driver-sql/src/sql-driver.ts b/packages/drivers/driver-sql/src/sql-driver.ts index 01a371e9b1..7e3cce7f70 100644 --- a/packages/drivers/driver-sql/src/sql-driver.ts +++ b/packages/drivers/driver-sql/src/sql-driver.ts @@ -21,7 +21,7 @@ import { parseAutonumberFormat, renderAutonumber, resolveAutonumberFormat, readA // "the protocol has no such function" refusal cannot drift from what // `AggregationNodeSchema.function` actually admits. import { AggregationFunction, emptyGroupValueFor } from '@objectstack/spec/data'; -import { STRUCTURED_JSON_TYPES, FILE_REFERENCE_TYPES, MULTI_OPTION_TYPES, NUMERIC_VALUE_TYPES } from '@objectstack/spec/data'; +import { STRUCTURED_JSON_TYPES, FILE_REFERENCE_TYPES, MULTI_OPTION_TYPES, NUMERIC_VALUE_TYPES, isMultiValueField } from '@objectstack/spec/data'; // [#16318] The per-field-type physical representation of the NUMERIC family. // `os generate migration` reads the SAME table, in both of its formats — that // shared table IS the repair, so ⛔ never restate one of its numbers here. @@ -250,8 +250,10 @@ export interface AutoNumberReservation { * column on one that has, so the question is asked per driver instance through * {@link SqlDriver.mediaColumnIsJson} rather than of this set. A * `multiple: true` media field is unaffected — its value is a LIST of ids, it - * is a JSON column on every deployment, and `!!field.multiple` already says so - * above every type check. + * is a JSON column on every deployment, and `isMultiValueField` already says so + * above every type check (`file` / `image` are `MULTI_CAPABLE_TYPES` members; + * #17469 refuses `multiple: true` on `avatar` / `video` / `audio` at the + * authoring entrance rather than arrayifying their column). * * ⛔ Do not re-add the family here. Every reader of this set treats membership * as deployment-independent, which is exactly what the family stopped being. @@ -14378,7 +14380,7 @@ export class SqlDriver implements IDataDriver { * * Reads `jsonFields` — the per-table registry both `initObjects` and * `registerExternalObject` fill from {@link SqlDriver.isJsonField}, i.e. - * `JSON_COLUMN_TYPES.has(type) || !!field.multiple`. Asking THAT registry + * `JSON_COLUMN_TYPES.has(type) || isMultiValueField(field)`. Asking THAT registry * rather than growing a second one is the whole point: `JSON_COLUMN_TYPES` * already carries a header calling itself the single source for the DDL * column-type switch and `isJsonField` "so the two can't drift", and a @@ -18022,9 +18024,30 @@ export class SqlDriver implements IDataDriver { return !this.fileColumnsMoved; } + /** + * Is this column a JSON column on this deployment? + * + * [#17469] (maintainer ruling 2026-09-13, decision batch #128 item 5, option + * 1′) The `multiple` half asks `@objectstack/spec`'s `isMultiValueField` + * rather than reading `field.multiple` raw, so the header above + * ("Membership is owned by @objectstack/spec") is now true for BOTH halves of + * this predicate. Before it, `!!field.multiple` arrayified the column for + * ANY type, while the spec predicate answered "not multi-value" for the same + * field — so a consumer shaping a query from the spec predicate composed `=` + * against a JSON array column and the driver answered 400. One definition of + * "multi-valued", and the shapes where the two used to disagree are refused + * at the authoring entrance by `FieldSchema` in the same ruling. + * + * ⚠️ The spec predicate reads the AUTHORABLE type vocabulary. A + * driver-internal alias (`string` / `integer` / `int` / `float`, the + * introspected-column spellings) is not a `FieldType`, so a `multiple: true` + * on one of those is no longer a JSON column — `object` / `array`, the + * aliases whose value really is structured, are in {@link JSON_COLUMN_TYPES} + * and keep their column unchanged. + */ protected isJsonField(type: string, field: any): boolean { if (!field.multiple && FILE_REFERENCE_TYPES.has(type)) return this.mediaColumnIsJson(); - return JSON_COLUMN_TYPES.has(type) || !!field.multiple; + return JSON_COLUMN_TYPES.has(type) || isMultiValueField({ type, multiple: field.multiple }); } // ── SQLite serialisation ──────────────────────────────────────────────────── diff --git a/packages/spec/src/data/field.test.ts b/packages/spec/src/data/field.test.ts index 66306c92e2..96453258a1 100644 --- a/packages/spec/src/data/field.test.ts +++ b/packages/spec/src/data/field.test.ts @@ -14,7 +14,7 @@ import { type CurrencyValue, } from './field.zod'; import { ObjectSchema } from './object.zod'; -import { MULTI_CAPABLE_TYPES, isMultiValueField } from './field-value.zod'; +import { MULTI_CAPABLE_TYPES, MULTI_OPTION_TYPES, isMultiValueField } from './field-value.zod'; describe('FieldType', () => { it('should accept valid field types', () => { @@ -2075,6 +2075,136 @@ describe('FieldSchema — authored `radio` + `multiple: true` is REFUSED (#11437 expect(isMultiValueField({ type: 'radio', multiple: true })).toBe(true); }); }); + +describe('FieldSchema — authored `multiple: true` on a NON-MULTI-CAPABLE type is REFUSED (#17469, maintainer ruling 2026-09-13, decision batch #128 item 5, option 1′)', () => { + // The #11437 radio rule generalised. `multiple` used to parse cleanly on + // every type: the UI rendered a single value, driver-sql built a JSON ARRAY + // column (`isJsonField`'s `|| !!field.multiple` clause), and `isMultiValueField` + // answered "not multi-value" for the same field — so a consumer that shaped + // its query from the spec predicate composed `=` against a JSON column and + // the driver answered 400. One definition of multi-valued, enforced here. + + const NON_DECLARABLE = ['text', 'textarea', 'number', 'boolean', 'date', 'datetime', + 'master_detail', 'tree', 'json', 'avatar', 'video', 'audio', 'formula', 'signature'] as const; + + it('REJECTS every non-declarable type, naming the field, the type and the illegal pair on the `multiple` path', () => { + for (const type of NON_DECLARABLE) { + const def: Record = { name: 'several', type, multiple: true }; + if (type === 'master_detail' || type === 'tree') def.reference = 'account'; + const r = FieldSchema.safeParse(def); + expect(r.success, `\`${type}\` + multiple: true must be REFUSED`).toBe(false); + if (!r.success) { + const issue = r.error.issues.find((i) => i.path.join('.') === 'multiple'); + expect(issue, `\`${type}\` must raise its issue on the \`multiple\` path`).toBeDefined(); + expect(issue!.message).toMatch(/"several"/); + expect(issue!.message).toContain(`'${type}'`); + expect(issue!.message).toMatch(/multiple: true/); + } + } + }); + + it('the rejection carries a remedy naming a multi-capable alternative (the ruling\'s own prescription)', () => { + const text = FieldSchema.safeParse({ name: 'labels', type: 'text', multiple: true }); + expect(text.success).toBe(false); + if (!text.success) { + const message = text.error.issues.map((i) => i.message).join('\n'); + expect(message).toMatch(/`multiselect`/); + expect(message).toMatch(/`checkboxes`/); + expect(message).toMatch(/`tags`/); + expect(message).toMatch(/`lookup`/); + } + // A reference type gets the reference-shaped remedy instead of the option list. + for (const type of ['master_detail', 'tree']) { + const r = FieldSchema.safeParse({ name: 'parents', type, reference: 'account', multiple: true }); + expect(r.success).toBe(false); + if (!r.success) { + const message = r.error.issues.map((i) => i.message).join('\n'); + expect(message).toMatch(/`lookup`/); + } + } + // A media type gets the media-shaped remedy. + const avatar = FieldSchema.safeParse({ name: 'faces', type: 'avatar', multiple: true }); + expect(avatar.success).toBe(false); + if (!avatar.success) { + const message = avatar.error.issues.map((i) => i.message).join('\n'); + expect(message).toMatch(/`file`/); + expect(message).toMatch(/`image`/); + } + }); + + it('the remedy enumeration is DERIVED from the two sets, and never offers `radio` (which its own rule refuses)', () => { + // #12017 two-copies failure shape: a hand-written enumeration goes stale. + const r = FieldSchema.safeParse({ name: 'labels', type: 'text', multiple: true }); + expect(r.success).toBe(false); + if (!r.success) { + const message = r.error.issues.find((i) => i.path.join('.') === 'multiple')!.message; + for (const t of [...MULTI_CAPABLE_TYPES, ...MULTI_OPTION_TYPES]) { + if (t === 'radio') continue; + expect(message, `the remedy list must name \`${t}\``).toContain(`\`${t}\``); + } + // `radio` + `multiple: true` is refused by #11437, so offering it would + // send the author from one refusal straight into another. + expect(message).not.toContain('`radio`'); + } + }); + + it('every DECLARABLE type still accepts `multiple: true` — the negative control', () => { + for (const type of [...MULTI_CAPABLE_TYPES, ...MULTI_OPTION_TYPES]) { + if (type === 'radio') continue; // its own #11437 refusal, pinned above + const def: Record = { name: 'several', type, multiple: true }; + if (type === 'lookup') def.reference = 'account'; + if (['select', 'multiselect', 'checkboxes'].includes(type)) { + def.options = [{ label: 'Alpha', value: 'alpha' }, { label: 'Beta', value: 'beta' }]; + } + const r = FieldSchema.safeParse(def); + expect(r.success, `\`${type}\` + multiple: true must stay ACCEPTED`).toBe(true); + } + }); + + it('`multiple: false` and an absent `multiple` stay accepted on a non-declarable type', () => { + // The refusal reads only the AUTHORED `true`; `multiple` materializes + // `.default(false)`, so this check can never fire on a defaulted value. + expect(FieldSchema.parse({ name: 'body', type: 'text' }).multiple).toBe(false); + expect(FieldSchema.parse({ name: 'body', type: 'text', multiple: false }).multiple).toBe(false); + }); + + it('parse(parse(x)) is stable on a non-declarable type — the materialized `false` re-parses cleanly (#9689 class)', () => { + const once = FieldSchema.parse({ name: 'body', label: 'Body', type: 'text' }); + expect(FieldSchema.parse(once)).toEqual(once); + }); + + it('fires through ObjectSchema too — the publish path an object document crosses', () => { + const r = ObjectSchema.safeParse({ + name: 'crm_lead', + label: 'Lead', + fields: { notes: { type: 'text', label: 'Notes', multiple: true } }, + }); + expect(r.success).toBe(false); + if (!r.success) { + expect(r.error.issues.map((i) => i.message).join('\n')).toMatch(/multiple: true/); + } + }); + + it('`radio` keeps its OWN narrower message — the two refusals never double-fire', () => { + const r = FieldSchema.safeParse({ name: 'severity', type: 'radio', multiple: true }); + expect(r.success).toBe(false); + if (!r.success) { + const onMultiple = r.error.issues.filter((i) => i.path.join('.') === 'multiple'); + expect(onMultiple).toHaveLength(1); + expect(onMultiple[0].message).toMatch(/a radio group is single-choice by definition/); + } + }); + + it('UNTOUCHED-HALF PIN — the ruling leaves `MULTI_CAPABLE_TYPES` and `isMultiValueField` alone', () => { + // Item 1 of the ruling states both are untouched: at-rest data keeps its + // read path, and the entrance is where the shape is refused. A "cleanup" + // that widens either to match the old driver behaviour trips this. + expect([...MULTI_CAPABLE_TYPES].sort()).toEqual(['file', 'image', 'lookup', 'radio', 'select', 'user']); + expect(isMultiValueField({ type: 'text', multiple: true })).toBe(false); + expect(isMultiValueField({ type: 'master_detail', multiple: true })).toBe(false); + expect(isMultiValueField({ type: 'tree', multiple: true })).toBe(false); + }); +}); describe('FieldSchema — `placeholder` is a DECLARED key (#9019, ruled Option C on objectui#4676)', () => { // The reverse of the pre-#9019 posture: `placeholder` used to be refused by // name via FIELD_KEY_GUIDANCE ("never a FieldSchema key. Author hint text diff --git a/packages/spec/src/data/field.zod.ts b/packages/spec/src/data/field.zod.ts index 977b998b54..f221da0598 100644 --- a/packages/spec/src/data/field.zod.ts +++ b/packages/spec/src/data/field.zod.ts @@ -27,7 +27,13 @@ import { discriminateDefaultValueShape, suggestDefaultValueToken, } from './default-value-shape'; -import { AddressSchema } from './field-value.zod'; +import { + AddressSchema, + FILE_REFERENCE_TYPES, + MULTI_CAPABLE_TYPES, + MULTI_OPTION_TYPES, + REFERENCE_VALUE_TYPES, +} from './field-value.zod'; // #7918 — the ISO 4217 / CLDR fraction-digit contradiction check (maintainer // ruling 2026-08-12, Option A). One shared verdict for both anchors: the // field-level `precision` key and `CurrencyConfigSchema.precision`. @@ -169,6 +175,33 @@ export const VALUE_DOMAIN_FIELD_TYPES: ReadonlySet = new Set([ 'text', ] as const satisfies readonly FieldType[]); +/** + * The types on which `multiple: true` is DECLARABLE — `MULTI_CAPABLE_TYPES` ∪ + * `MULTI_OPTION_TYPES`, derived from the two sets rather than re-listed + * (#12017 two-copies failure shape; the #11875 lesson that a hand-written + * enumeration goes one revision stale). + * + * Package-internal on purpose: it is the superRefine's membership test, not a + * third answer to "is this field multi-valued". That question has exactly one + * answer — `isMultiValueField` (`field-value.zod.ts`) — and both of the sets + * this one unions stay untouched. + */ +const MULTI_DECLARABLE_TYPES: ReadonlySet = new Set([ + ...MULTI_CAPABLE_TYPES, ...MULTI_OPTION_TYPES, +]); + +/** + * What the refusal above offers as a remedy: the declarable set MINUS `radio`. + * + * `radio` is multi-CAPABLE by the value contract — `isMultiValueField` still + * promotes it, which is the #11437 ruling's untouched half — and is refused at + * the authoring seam by that same ruling's own check. Offering it here would + * send the author from one refusal straight into another, so it is filtered + * out mechanically instead of by a second hand-written list. + */ +const MULTI_DECLARABLE_REMEDY_TYPES: readonly string[] = + [...MULTI_DECLARABLE_TYPES].filter((t) => t !== 'radio'); + /** * Field types whose value is edited in a MULTILINE text editor whose inline * (non-fullscreen) surface is sized by the HTML `rows` attribute — the set on @@ -1043,7 +1076,7 @@ export const FieldSchema = lazySchema(() => { * branch. Same ruling: `required` on a multi-value lookup means non-empty * array (see `required` above). */ - multiple: z.boolean().default(false).describe('Allow multiple values (Stores as Array/JSON). Applicable for select, lookup, file, image. An emptied multi-value lookup reads back as `[]`, never `null` — the rule binds every writer (cascade repair, form clears, API writes), not just cascade repair (maintainer ruling 2026-08-18).'), + multiple: z.boolean().default(false).describe('Allow multiple values (Stores as Array/JSON). Declarable ONLY on the multi-capable types — select, lookup, user, file, image — and redundantly on the inherently-multi option types (multiselect, checkboxes, tags); `multiple: true` on any other type is REFUSED at parse (maintainer ruling 2026-09-13), and on `radio` by the narrower 2026-08-22 ruling. An emptied multi-value lookup reads back as `[]`, never `null` — the rule binds every writer (cascade repair, form clears, API writes), not just cascade repair (maintainer ruling 2026-08-18).'), // `true` = unique WITHIN the tenant on a tenant-scoped object (composite // `(tenantField, field)` index); `'global'` = platform-wide single-column // unique. See {@link UniqueScopeSchema} for the scope vocabulary (ADR-0120). @@ -1980,6 +2013,53 @@ export const FieldSchema = lazySchema(() => { }); } + // [#17469] (maintainer ruling 2026-09-13, decision batch #128 item 5, option + // 1′ — the #11437 radio rule GENERALISED): an authored `multiple: true` is + // refused on every type outside MULTI_DECLARABLE_TYPES + // (`MULTI_CAPABLE_TYPES` ∪ `MULTI_OPTION_TYPES`). + // + // "This cell holds several values at once" has business meaning only on + // multi-select, multi-record / multi-user and multi-file fields — exactly + // what the spec already declares. A child record with several masters, a + // tree node with several parents, or a text box holding several texts has no + // business meaning on any mainstream platform. Today such a declaration is + // ACCEPTED silently, the UI renders a single-value control, driver-sql builds + // a JSON ARRAY column for it (`isJsonField`), and the related list's equality + // filter then answers the user a 400 — because `isMultiValueField` says "not + // multi-value" for the same field the storage layer arrayified. One + // definition of multi-valued, enforced at the entrance; the driver half of + // the ruling makes storage derive from that same predicate. + // + // `MULTI_CAPABLE_TYPES` / `isMultiValueField` (field-value.zod.ts) stay + // UNTOUCHED, exactly as in #11437: at-rest data keeps its read path and the + // ADR-0087 semantic entry (`field-multiple-non-capable-type-refused`) carries + // the hand-migration for any stored field that predates this. `multiple` + // materializes `.default(false)` above, so `true` here is always AUTHORED — + // this check can never fire on a defaulted value, and `parse(parse(x))` stays + // stable (#9689 class; pinned in field.test.ts). + // + // `radio` is INSIDE the set and is therefore never refused here — its own + // narrower #11437 check above owns that pair, so the two never double-fire. + if (field.multiple === true && !MULTI_DECLARABLE_TYPES.has(field.type)) { + const alternative = REFERENCE_VALUE_TYPES.has(field.type) + ? 'a `lookup` with `reference` naming the same object (`multiple: true` there stores several related records)' + : FILE_REFERENCE_TYPES.has(field.type) + ? 'a `file` or `image` field, both of which take `multiple: true` for several attachments' + : '`multiselect` (dropdown), `checkboxes` (all options visible) or `tags` (free-form values) for several ' + + 'option codes, or a `lookup` with `multiple: true` for several related records'; + ctx.addIssue({ + code: 'custom', + path: ['multiple'], + message: + `Field "${field.name ?? ''}": \`type: '${field.type}'\` cannot be combined with ` + + '`multiple: true` — a cell holding several values at once is declared only on ' + + `${MULTI_DECLARABLE_REMEDY_TYPES.map((t) => `\`${t}\``).join(', ')}, and \`${field.type}\` is not ` + + 'one of them: the declaration would parse while the widget renders a single value, the SQL ' + + 'driver builds a JSON array column for it, and every `=` filter against that column is then ' + + `answered 400. Use ${alternative}. For a single value, drop \`multiple\`.`, + }); + } + // [#11566] (maintainer ruling 2026-08-24 — 「四维分析一致的,接手你的建议。」): // `maxLength` is only authorable on types that store a bounded string. // The key sat on the BASE schema, so it was legal on `boolean` / `lookup` / diff --git a/packages/spec/src/migrations/entries/semantic/18.field-multiple-non-capable-type-refused.ts b/packages/spec/src/migrations/entries/semantic/18.field-multiple-non-capable-type-refused.ts new file mode 100644 index 0000000000..8b392ff669 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.field-multiple-non-capable-type-refused.ts @@ -0,0 +1,44 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +export const entry: SemanticMigration = { + id: 'field-multiple-non-capable-type-refused', + surface: 'object.fields..multiple — an authored `multiple: true` on a field whose ' + + '`type` is outside MULTI_CAPABLE_TYPES (`select` / `radio` / `lookup` / `user` / `file` / ' + + '`image`) union MULTI_OPTION_TYPES (`multiselect` / `checkboxes` / `tags`) — e.g. ' + + '`master_detail`, `tree`, `text`, `boolean`, `datetime`, `avatar`', + replacement: 'a multi-capable type that actually holds several values: `multiselect` / ' + + '`checkboxes` / `tags` for several option codes, a `lookup` with `multiple: true` for ' + + 'several related records (the replacement for a multi-valued `master_detail` / `tree`), ' + + '`file` / `image` with `multiple: true` for several attachments — or, where the field ' + + 'really does hold one value, dropping the `multiple` key. `MULTI_CAPABLE_TYPES` and ' + + '`isMultiValueField` are unchanged, so every field that was ALREADY multi-valued by that ' + + 'predicate keeps its declaration, its storage and its read path verbatim.', + reason: + '#17469 (maintainer ruling 2026-09-13, decision batch #128 item 5, option 1′ — the #11437 ' + + 'radio rule generalised): two definitions of "multi-valued" disagreed. `FieldSchema` ' + + 'accepted `multiple: true` on ANY type; driver-sql\'s `isJsonField` read it raw ' + + '(`|| !!field.multiple`) and built a JSON ARRAY column; `isMultiValueField` — the spec ' + + 'predicate consumers shape queries from — answered "not multi-value" for the same field. ' + + 'A related list therefore composed `=` against a JSON array column and the driver answered ' + + 'the user a 400 (objectui#8886 pinned the divergence on the consumer side; objectui#8937 ' + + 'recorded it as owed and not filed). There is NO lossless conversion: the column was ' + + 'physically built as a JSON array, so the stored value is an array while the replacement ' + + 'type may want one scalar, several ids, or several option codes — which of those the author ' + + 'meant is a business judgment the chain cannot make. Hence a structured TODO rather than an ' + + 'auto-rewrite (ADR-0087 D3 "never silence", ADR-0032 "no silent failure"). Population ' + + 'measured at ruling time: 0 in-tree and 0 in HotCRM (shallow clone c716a2c) — every ' + + '`multiple: true` there is on `lookup` / `select`.', + acceptanceCriteria: + 'Every field in the stack parses: `ObjectSchema.parse()` / `objectstack validate` report no ' + + 'issue on the `multiple` path. For each field the refusal names — the message states the ' + + 'object-qualified field name and its `type` — the author has either dropped `multiple` or ' + + 'moved the field to a multi-capable type AND migrated the stored column, because the two ' + + 'storages differ: the old column holds a JSON array, the new one holds a scalar (dropping ' + + '`multiple`) or a differently-shaped array (changing `type`). Prove the data half by ' + + 'reading one migrated row back through the API and asserting the value shape the new ' + + 'declaration promises; `=` filters against the field answer rows instead of a 400. Fields ' + + 'already multi-valued by `isMultiValueField` need no change and must read back ' + + 'byte-identically.', +}; From f0bc3b9229153a87b71e2867e79ac69bea22abcc Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 14 Sep 2026 10:42:58 +0000 Subject: [PATCH 2/6] chore(spec): regenerate migration registry and reference docs for the #17469 entry Claude-Session: https://claude.ai/code/session_01KB5PFtxuy1x3dcR5gxudx6 Co-authored-by: Claude --- content/docs/references/data/field.mdx | 2 +- content/docs/references/data/object.mdx | 4 +- content/docs/references/system/migration.mdx | 4 +- packages/spec/src/migrations/registry.ts | 40 ++++++++++++++++++++ 4 files changed, 45 insertions(+), 5 deletions(-) diff --git a/content/docs/references/data/field.mdx b/content/docs/references/data/field.mdx index da4beff2da..c7771ff831 100644 --- a/content/docs/references/data/field.mdx +++ b/content/docs/references/data/field.mdx @@ -60,7 +60,7 @@ const result = CurrencyConfigSchema.parse(data); | **required** | `boolean` | optional (default: `false`) | Write-time contract (ADR-0113): an insert must provide a non-null value, and an update may not null it out. On a multi-value lookup (`multiple: true`) required means NON-EMPTY array — an emptied required set fails validation loudly; `[]` does not satisfy it (maintainer ruling 2026-08-18). NOT a column constraint — the physical NOT NULL is a separate explicit opt-in (`storage.notNull`), so tightening this on a deployed object is safe: existing null rows stay readable, and editable as long as the write does not touch this field. | | **storage** | `{ notNull?: boolean }` | optional | Physical storage constraints (ADR-0113). Owns the DDL the write contract deliberately does not imply. Absent = no storage-level constraint requested. | | **searchable** | `boolean` | optional (default: `false`) | Is searchable | -| **multiple** | `boolean` | optional (default: `false`) | Allow multiple values (Stores as Array/JSON). Applicable for select, lookup, file, image. An emptied multi-value lookup reads back as `[]`, never `null` — the rule binds every writer (cascade repair, form clears, API writes), not just cascade repair (maintainer ruling 2026-08-18). | +| **multiple** | `boolean` | optional (default: `false`) | Allow multiple values (Stores as Array/JSON). Declarable ONLY on the multi-capable types — select, lookup, user, file, image — and redundantly on the inherently-multi option types (multiselect, checkboxes, tags); `multiple: true` on any other type is REFUSED at parse (maintainer ruling 2026-09-13), and on `radio` by the narrower 2026-08-22 ruling. An emptied multi-value lookup reads back as `[]`, never `null` — the rule binds every writer (cascade repair, form clears, API writes), not just cascade repair (maintainer ruling 2026-08-18). | | **unique** | `boolean \| 'global' \| 'organization'` | optional | Unique constraint and its scope (ADR-0120). 'organization' = one holder per organization (NULL-safe composite with the organization key part on organization-scoped objects) — prefer this explicit spelling in new code; true = same per-organization scope (positional synonym, stays valid); 'global' = one holder across the whole installation. 'tenant'/'org' are rejected — the word is 'organization'. Omitted ⇒ false, EXCEPT on an `autonumber` field, where omitted ⇒ 'organization' (an auto-number is a business identifier, so the platform makes it unique per organization by default — the same tenant-composite shape an explicit `unique: true` produces). To opt an autonumber field out, write `unique: false` explicitly — legitimate only for a display-only sequence that is not used to identify the record; the platform's duplicate scan (`os migrate duplicates`) still treats every autonumber field as an identifier. | | **defaultValue** | `any` | optional | Default applied on INSERT when the field is omitted or null (`''` is a real value, not absence). Three legal shapes, discriminated in the engine's own order: a CEL Expression envelope `{ dialect: 'cel', source: 'today()' }` (accepted structurally; result type is a runtime concern); a runtime TOKEN — `NOW()` on `datetime`/`date`/`time` only, `current_user` on `user` or `lookup` with `reference: 'sys_user'` only, neither on a multi-value field; or a LITERAL, which must satisfy this field's own stored value contract (ADR-0104 D1 `valueSchemaFor`). Anything else is refused at parse time with a prescriptive message. | | **maxLength** | `integer` | optional | Max character length (positive integer). Only authorable on types that store a bounded string: text, textarea, email, url, phone, password, markdown, html, richtext, code, signature, qrcode. Checked on the WRITTEN value only (the `min`/`max` transition-gate class): a stored value longer than a bound declared later is never re-read and survives unrelated edits — only a write carrying an over-long value is refused. | diff --git a/content/docs/references/data/object.mdx b/content/docs/references/data/object.mdx index ea6b372c16..d060942dc5 100644 --- a/content/docs/references/data/object.mdx +++ b/content/docs/references/data/object.mdx @@ -222,7 +222,7 @@ const result = ApiMethod.parse(data); | **required** | `boolean` | optional (default: `false`) | Write-time contract (ADR-0113): an insert must provide a non-null value, and an update may not null it out. On a multi-value lookup (`multiple: true`) required means NON-EMPTY array — an emptied required set fails validation loudly; `[]` does not satisfy it (maintainer ruling 2026-08-18). NOT a column constraint — the physical NOT NULL is a separate explicit opt-in (`storage.notNull`), so tightening this on a deployed object is safe: existing null rows stay readable, and editable as long as the write does not touch this field. | | **storage** | `{ notNull?: boolean }` | optional | Physical storage constraints (ADR-0113). Owns the DDL the write contract deliberately does not imply. Absent = no storage-level constraint requested. | | **searchable** | `boolean` | optional (default: `false`) | Is searchable | -| **multiple** | `boolean` | optional (default: `false`) | Allow multiple values (Stores as Array/JSON). Applicable for select, lookup, file, image. An emptied multi-value lookup reads back as `[]`, never `null` — the rule binds every writer (cascade repair, form clears, API writes), not just cascade repair (maintainer ruling 2026-08-18). | +| **multiple** | `boolean` | optional (default: `false`) | Allow multiple values (Stores as Array/JSON). Declarable ONLY on the multi-capable types — select, lookup, user, file, image — and redundantly on the inherently-multi option types (multiselect, checkboxes, tags); `multiple: true` on any other type is REFUSED at parse (maintainer ruling 2026-09-13), and on `radio` by the narrower 2026-08-22 ruling. An emptied multi-value lookup reads back as `[]`, never `null` — the rule binds every writer (cascade repair, form clears, API writes), not just cascade repair (maintainer ruling 2026-08-18). | | **unique** | `boolean \| 'global' \| 'organization'` | optional | Unique constraint and its scope (ADR-0120). 'organization' = one holder per organization (NULL-safe composite with the organization key part on organization-scoped objects) — prefer this explicit spelling in new code; true = same per-organization scope (positional synonym, stays valid); 'global' = one holder across the whole installation. 'tenant'/'org' are rejected — the word is 'organization'. Omitted ⇒ false, EXCEPT on an `autonumber` field, where omitted ⇒ 'organization' (an auto-number is a business identifier, so the platform makes it unique per organization by default — the same tenant-composite shape an explicit `unique: true` produces). To opt an autonumber field out, write `unique: false` explicitly — legitimate only for a display-only sequence that is not used to identify the record; the platform's duplicate scan (`os migrate duplicates`) still treats every autonumber field as an identifier. | | **defaultValue** | `any` | optional | Default applied on INSERT when the field is omitted or null (`''` is a real value, not absence). Three legal shapes, discriminated in the engine's own order: a CEL Expression envelope `{ dialect: 'cel', source: 'today()' }` (accepted structurally; result type is a runtime concern); a runtime TOKEN — `NOW()` on `datetime`/`date`/`time` only, `current_user` on `user` or `lookup` with `reference: 'sys_user'` only, neither on a multi-value field; or a LITERAL, which must satisfy this field's own stored value contract (ADR-0104 D1 `valueSchemaFor`). Anything else is refused at parse time with a prescriptive message. | | **maxLength** | `integer` | optional | Max character length (positive integer). Only authorable on types that store a bounded string: text, textarea, email, url, phone, password, markdown, html, richtext, code, signature, qrcode. Checked on the WRITTEN value only (the `min`/`max` transition-gate class): a stored value longer than a bound declared later is never re-read and survives unrelated edits — only a write carrying an over-long value is refused. | @@ -555,7 +555,7 @@ const result = ApiMethod.parse(data); | **required** | `boolean` | optional (default: `false`) | Write-time contract (ADR-0113): an insert must provide a non-null value, and an update may not null it out. On a multi-value lookup (`multiple: true`) required means NON-EMPTY array — an emptied required set fails validation loudly; `[]` does not satisfy it (maintainer ruling 2026-08-18). NOT a column constraint — the physical NOT NULL is a separate explicit opt-in (`storage.notNull`), so tightening this on a deployed object is safe: existing null rows stay readable, and editable as long as the write does not touch this field. | | **storage** | `{ notNull?: boolean }` | optional | Physical storage constraints (ADR-0113). Owns the DDL the write contract deliberately does not imply. Absent = no storage-level constraint requested. | | **searchable** | `boolean` | optional (default: `false`) | Is searchable | -| **multiple** | `boolean` | optional (default: `false`) | Allow multiple values (Stores as Array/JSON). Applicable for select, lookup, file, image. An emptied multi-value lookup reads back as `[]`, never `null` — the rule binds every writer (cascade repair, form clears, API writes), not just cascade repair (maintainer ruling 2026-08-18). | +| **multiple** | `boolean` | optional (default: `false`) | Allow multiple values (Stores as Array/JSON). Declarable ONLY on the multi-capable types — select, lookup, user, file, image — and redundantly on the inherently-multi option types (multiselect, checkboxes, tags); `multiple: true` on any other type is REFUSED at parse (maintainer ruling 2026-09-13), and on `radio` by the narrower 2026-08-22 ruling. An emptied multi-value lookup reads back as `[]`, never `null` — the rule binds every writer (cascade repair, form clears, API writes), not just cascade repair (maintainer ruling 2026-08-18). | | **unique** | `boolean \| 'global' \| 'organization'` | optional | Unique constraint and its scope (ADR-0120). 'organization' = one holder per organization (NULL-safe composite with the organization key part on organization-scoped objects) — prefer this explicit spelling in new code; true = same per-organization scope (positional synonym, stays valid); 'global' = one holder across the whole installation. 'tenant'/'org' are rejected — the word is 'organization'. Omitted ⇒ false, EXCEPT on an `autonumber` field, where omitted ⇒ 'organization' (an auto-number is a business identifier, so the platform makes it unique per organization by default — the same tenant-composite shape an explicit `unique: true` produces). To opt an autonumber field out, write `unique: false` explicitly — legitimate only for a display-only sequence that is not used to identify the record; the platform's duplicate scan (`os migrate duplicates`) still treats every autonumber field as an identifier. | | **defaultValue** | `any` | optional | Default applied on INSERT when the field is omitted or null (`''` is a real value, not absence). Three legal shapes, discriminated in the engine's own order: a CEL Expression envelope `{ dialect: 'cel', source: 'today()' }` (accepted structurally; result type is a runtime concern); a runtime TOKEN — `NOW()` on `datetime`/`date`/`time` only, `current_user` on `user` or `lookup` with `reference: 'sys_user'` only, neither on a multi-value field; or a LITERAL, which must satisfy this field's own stored value contract (ADR-0104 D1 `valueSchemaFor`). Anything else is refused at parse time with a prescriptive message. | | **maxLength** | `integer` | optional | Max character length (positive integer). Only authorable on types that store a bounded string: text, textarea, email, url, phone, password, markdown, html, richtext, code, signature, qrcode. Checked on the WRITTEN value only (the `min`/`max` transition-gate class): a stored value longer than a bound declared later is never re-read and survives unrelated edits — only a write carrying an over-long value is refused. | diff --git a/content/docs/references/system/migration.mdx b/content/docs/references/system/migration.mdx index 29bfbc091b..b77887d268 100644 --- a/content/docs/references/system/migration.mdx +++ b/content/docs/references/system/migration.mdx @@ -60,7 +60,7 @@ Add a new field to an existing object | **required** | `boolean` | optional (default: `false`) | Write-time contract (ADR-0113): an insert must provide a non-null value, and an update may not null it out. On a multi-value lookup (`multiple: true`) required means NON-EMPTY array — an emptied required set fails validation loudly; `[]` does not satisfy it (maintainer ruling 2026-08-18). NOT a column constraint — the physical NOT NULL is a separate explicit opt-in (`storage.notNull`), so tightening this on a deployed object is safe: existing null rows stay readable, and editable as long as the write does not touch this field. | | **storage** | `{ notNull?: boolean }` | optional | Physical storage constraints (ADR-0113). Owns the DDL the write contract deliberately does not imply. Absent = no storage-level constraint requested. | | **searchable** | `boolean` | optional (default: `false`) | Is searchable | -| **multiple** | `boolean` | optional (default: `false`) | Allow multiple values (Stores as Array/JSON). Applicable for select, lookup, file, image. An emptied multi-value lookup reads back as `[]`, never `null` — the rule binds every writer (cascade repair, form clears, API writes), not just cascade repair (maintainer ruling 2026-08-18). | +| **multiple** | `boolean` | optional (default: `false`) | Allow multiple values (Stores as Array/JSON). Declarable ONLY on the multi-capable types — select, lookup, user, file, image — and redundantly on the inherently-multi option types (multiselect, checkboxes, tags); `multiple: true` on any other type is REFUSED at parse (maintainer ruling 2026-09-13), and on `radio` by the narrower 2026-08-22 ruling. An emptied multi-value lookup reads back as `[]`, never `null` — the rule binds every writer (cascade repair, form clears, API writes), not just cascade repair (maintainer ruling 2026-08-18). | | **unique** | `boolean \| 'global' \| 'organization'` | optional | Unique constraint and its scope (ADR-0120). 'organization' = one holder per organization (NULL-safe composite with the organization key part on organization-scoped objects) — prefer this explicit spelling in new code; true = same per-organization scope (positional synonym, stays valid); 'global' = one holder across the whole installation. 'tenant'/'org' are rejected — the word is 'organization'. Omitted ⇒ false, EXCEPT on an `autonumber` field, where omitted ⇒ 'organization' (an auto-number is a business identifier, so the platform makes it unique per organization by default — the same tenant-composite shape an explicit `unique: true` produces). To opt an autonumber field out, write `unique: false` explicitly — legitimate only for a display-only sequence that is not used to identify the record; the platform's duplicate scan (`os migrate duplicates`) still treats every autonumber field as an identifier. | | **defaultValue** | `any` | optional | Default applied on INSERT when the field is omitted or null (`''` is a real value, not absence). Three legal shapes, discriminated in the engine's own order: a CEL Expression envelope `{ dialect: 'cel', source: 'today()' }` (accepted structurally; result type is a runtime concern); a runtime TOKEN — `NOW()` on `datetime`/`date`/`time` only, `current_user` on `user` or `lookup` with `reference: 'sys_user'` only, neither on a multi-value field; or a LITERAL, which must satisfy this field's own stored value contract (ADR-0104 D1 `valueSchemaFor`). Anything else is refused at parse time with a prescriptive message. | | **maxLength** | `integer` | optional | Max character length (positive integer). Only authorable on types that store a bounded string: text, textarea, email, url, phone, password, markdown, html, richtext, code, signature, qrcode. Checked on the WRITTEN value only (the `min`/`max` transition-gate class): a stored value longer than a bound declared later is never re-read and survives unrelated edits — only a write carrying an over-long value is refused. | @@ -480,7 +480,7 @@ Add a new field to an existing object | **required** | `boolean` | optional (default: `false`) | Write-time contract (ADR-0113): an insert must provide a non-null value, and an update may not null it out. On a multi-value lookup (`multiple: true`) required means NON-EMPTY array — an emptied required set fails validation loudly; `[]` does not satisfy it (maintainer ruling 2026-08-18). NOT a column constraint — the physical NOT NULL is a separate explicit opt-in (`storage.notNull`), so tightening this on a deployed object is safe: existing null rows stay readable, and editable as long as the write does not touch this field. | | **storage** | `{ notNull?: boolean }` | optional | Physical storage constraints (ADR-0113). Owns the DDL the write contract deliberately does not imply. Absent = no storage-level constraint requested. | | **searchable** | `boolean` | optional (default: `false`) | Is searchable | -| **multiple** | `boolean` | optional (default: `false`) | Allow multiple values (Stores as Array/JSON). Applicable for select, lookup, file, image. An emptied multi-value lookup reads back as `[]`, never `null` — the rule binds every writer (cascade repair, form clears, API writes), not just cascade repair (maintainer ruling 2026-08-18). | +| **multiple** | `boolean` | optional (default: `false`) | Allow multiple values (Stores as Array/JSON). Declarable ONLY on the multi-capable types — select, lookup, user, file, image — and redundantly on the inherently-multi option types (multiselect, checkboxes, tags); `multiple: true` on any other type is REFUSED at parse (maintainer ruling 2026-09-13), and on `radio` by the narrower 2026-08-22 ruling. An emptied multi-value lookup reads back as `[]`, never `null` — the rule binds every writer (cascade repair, form clears, API writes), not just cascade repair (maintainer ruling 2026-08-18). | | **unique** | `boolean \| 'global' \| 'organization'` | optional | Unique constraint and its scope (ADR-0120). 'organization' = one holder per organization (NULL-safe composite with the organization key part on organization-scoped objects) — prefer this explicit spelling in new code; true = same per-organization scope (positional synonym, stays valid); 'global' = one holder across the whole installation. 'tenant'/'org' are rejected — the word is 'organization'. Omitted ⇒ false, EXCEPT on an `autonumber` field, where omitted ⇒ 'organization' (an auto-number is a business identifier, so the platform makes it unique per organization by default — the same tenant-composite shape an explicit `unique: true` produces). To opt an autonumber field out, write `unique: false` explicitly — legitimate only for a display-only sequence that is not used to identify the record; the platform's duplicate scan (`os migrate duplicates`) still treats every autonumber field as an identifier. | | **defaultValue** | `any` | optional | Default applied on INSERT when the field is omitted or null (`''` is a real value, not absence). Three legal shapes, discriminated in the engine's own order: a CEL Expression envelope `{ dialect: 'cel', source: 'today()' }` (accepted structurally; result type is a runtime concern); a runtime TOKEN — `NOW()` on `datetime`/`date`/`time` only, `current_user` on `user` or `lookup` with `reference: 'sys_user'` only, neither on a multi-value field; or a LITERAL, which must satisfy this field's own stored value contract (ADR-0104 D1 `valueSchemaFor`). Anything else is refused at parse time with a prescriptive message. | | **maxLength** | `integer` | optional | Max character length (positive integer). Only authorable on types that store a bounded string: text, textarea, email, url, phone, password, markdown, html, richtext, code, signature, qrcode. Checked on the WRITTEN value only (the `min`/`max` transition-gate class): a stored value longer than a bound declared later is never re-read and survives unrelated edits — only a write carrying an over-long value is refused. | diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index 4d504ab5ef..5dd7679210 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -7971,6 +7971,46 @@ const step18: MigrationStep = { + 'meant; a wanted minimum is re-declared as a positive integer and enforced by the ' + 'write-time validator from the next write on.', }, + { + id: 'field-multiple-non-capable-type-refused', + surface: 'object.fields..multiple — an authored `multiple: true` on a field whose ' + + '`type` is outside MULTI_CAPABLE_TYPES (`select` / `radio` / `lookup` / `user` / `file` / ' + + '`image`) union MULTI_OPTION_TYPES (`multiselect` / `checkboxes` / `tags`) — e.g. ' + + '`master_detail`, `tree`, `text`, `boolean`, `datetime`, `avatar`', + replacement: 'a multi-capable type that actually holds several values: `multiselect` / ' + + '`checkboxes` / `tags` for several option codes, a `lookup` with `multiple: true` for ' + + 'several related records (the replacement for a multi-valued `master_detail` / `tree`), ' + + '`file` / `image` with `multiple: true` for several attachments — or, where the field ' + + 'really does hold one value, dropping the `multiple` key. `MULTI_CAPABLE_TYPES` and ' + + '`isMultiValueField` are unchanged, so every field that was ALREADY multi-valued by that ' + + 'predicate keeps its declaration, its storage and its read path verbatim.', + reason: + '#17469 (maintainer ruling 2026-09-13, decision batch #128 item 5, option 1′ — the #11437 ' + + 'radio rule generalised): two definitions of "multi-valued" disagreed. `FieldSchema` ' + + 'accepted `multiple: true` on ANY type; driver-sql\'s `isJsonField` read it raw ' + + '(`|| !!field.multiple`) and built a JSON ARRAY column; `isMultiValueField` — the spec ' + + 'predicate consumers shape queries from — answered "not multi-value" for the same field. ' + + 'A related list therefore composed `=` against a JSON array column and the driver answered ' + + 'the user a 400 (objectui#8886 pinned the divergence on the consumer side; objectui#8937 ' + + 'recorded it as owed and not filed). There is NO lossless conversion: the column was ' + + 'physically built as a JSON array, so the stored value is an array while the replacement ' + + 'type may want one scalar, several ids, or several option codes — which of those the author ' + + 'meant is a business judgment the chain cannot make. Hence a structured TODO rather than an ' + + 'auto-rewrite (ADR-0087 D3 "never silence", ADR-0032 "no silent failure"). Population ' + + 'measured at ruling time: 0 in-tree and 0 in HotCRM (shallow clone c716a2c) — every ' + + '`multiple: true` there is on `lookup` / `select`.', + acceptanceCriteria: + 'Every field in the stack parses: `ObjectSchema.parse()` / `objectstack validate` report no ' + + 'issue on the `multiple` path. For each field the refusal names — the message states the ' + + 'object-qualified field name and its `type` — the author has either dropped `multiple` or ' + + 'moved the field to a multi-capable type AND migrated the stored column, because the two ' + + 'storages differ: the old column holds a JSON array, the new one holds a scalar (dropping ' + + '`multiple`) or a differently-shaped array (changing `type`). Prove the data half by ' + + 'reading one migrated row back through the API and asserting the value shape the new ' + + 'declaration promises; `=` filters against the field answer rows instead of a 400. Fields ' + + 'already multi-valued by `isMultiValueField` need no change and must read back ' + + 'byte-identically.', + }, { id: 'field-scale-precision-integer-refused', surface: 'object field `scale` / `precision` declarations (`Field.number` and friends) — ' From acb046912fffbdf917c7aa27664b3a331122d7f1 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 14 Sep 2026 10:49:52 +0000 Subject: [PATCH 3/6] fix(spec): compute the multi-declarable sets lazily to avoid an ESM init cycle `field-value.zod` reaches back into `field.zod` through `shared/strict-object` -> `shared/suggestions.zod`, so spreading the two sets at module top level threw `MULTI_CAPABLE_TYPES is not iterable` on the import orders that enter `field-value.zod` first (six spec suites). Claude-Session: https://claude.ai/code/session_01KB5PFtxuy1x3dcR5gxudx6 Co-authored-by: Claude --- packages/spec/src/data/field.zod.ts | 27 +++++++++++++++++++-------- 1 file changed, 19 insertions(+), 8 deletions(-) diff --git a/packages/spec/src/data/field.zod.ts b/packages/spec/src/data/field.zod.ts index f221da0598..315e9e4607 100644 --- a/packages/spec/src/data/field.zod.ts +++ b/packages/spec/src/data/field.zod.ts @@ -185,13 +185,22 @@ export const VALUE_DOMAIN_FIELD_TYPES: ReadonlySet = new Set([ * third answer to "is this field multi-valued". That question has exactly one * answer — `isMultiValueField` (`field-value.zod.ts`) — and both of the sets * this one unions stay untouched. + * + * ⚠️ Computed on FIRST USE, never at module top level. `field-value.zod` reaches + * back into this module through `shared/strict-object` → `shared/suggestions.zod`, + * so on the import orders that enter `field-value.zod` first its `const` bindings + * are still in the temporal dead zone while THIS module body runs — measured as + * `TypeError: MULTI_CAPABLE_TYPES is not iterable` in six spec suites. Spreading + * the sets inside the refinement (which runs at parse time, long after every + * module has settled) is the same discipline `lazySchema` applies above. */ -const MULTI_DECLARABLE_TYPES: ReadonlySet = new Set([ - ...MULTI_CAPABLE_TYPES, ...MULTI_OPTION_TYPES, -]); +let multiDeclarableTypes: ReadonlySet | undefined; +function multiDeclarableTypeSet(): ReadonlySet { + return (multiDeclarableTypes ??= new Set([...MULTI_CAPABLE_TYPES, ...MULTI_OPTION_TYPES])); +} /** - * What the refusal above offers as a remedy: the declarable set MINUS `radio`. + * What the refusal offers as a remedy: the declarable set MINUS `radio`. * * `radio` is multi-CAPABLE by the value contract — `isMultiValueField` still * promotes it, which is the #11437 ruling's untouched half — and is refused at @@ -199,8 +208,10 @@ const MULTI_DECLARABLE_TYPES: ReadonlySet = new Set([ * send the author from one refusal straight into another, so it is filtered * out mechanically instead of by a second hand-written list. */ -const MULTI_DECLARABLE_REMEDY_TYPES: readonly string[] = - [...MULTI_DECLARABLE_TYPES].filter((t) => t !== 'radio'); +let multiDeclarableRemedyTypes: readonly string[] | undefined; +function multiDeclarableRemedyTypeList(): readonly string[] { + return (multiDeclarableRemedyTypes ??= [...multiDeclarableTypeSet()].filter((t) => t !== 'radio')); +} /** * Field types whose value is edited in a MULTILINE text editor whose inline @@ -2040,7 +2051,7 @@ export const FieldSchema = lazySchema(() => { // // `radio` is INSIDE the set and is therefore never refused here — its own // narrower #11437 check above owns that pair, so the two never double-fire. - if (field.multiple === true && !MULTI_DECLARABLE_TYPES.has(field.type)) { + if (field.multiple === true && !multiDeclarableTypeSet().has(field.type)) { const alternative = REFERENCE_VALUE_TYPES.has(field.type) ? 'a `lookup` with `reference` naming the same object (`multiple: true` there stores several related records)' : FILE_REFERENCE_TYPES.has(field.type) @@ -2053,7 +2064,7 @@ export const FieldSchema = lazySchema(() => { message: `Field "${field.name ?? ''}": \`type: '${field.type}'\` cannot be combined with ` + '`multiple: true` — a cell holding several values at once is declared only on ' + - `${MULTI_DECLARABLE_REMEDY_TYPES.map((t) => `\`${t}\``).join(', ')}, and \`${field.type}\` is not ` + + `${multiDeclarableRemedyTypeList().map((t) => `\`${t}\``).join(', ')}, and \`${field.type}\` is not ` + 'one of them: the declaration would parse while the widget renders a single value, the SQL ' + 'driver builds a JSON array column for it, and every `=` filter against that column is then ' + `answered 400. Use ${alternative}. For a single value, drop \`multiple\`.`, From 44f11fefd8e60a393d8009c24f5ddd3a73aa7e5a Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 14 Sep 2026 11:15:48 +0000 Subject: [PATCH 4/6] fix(driver-sql): derive every multi-value storage decision from the spec predicate, and retriage the fixtures it moves Claude-Session: https://claude.ai/code/session_01KB5PFtxuy1x3dcR5gxudx6 Co-authored-by: Claude --- .../schema-drift.base-type-mismatch.test.ts | 14 +- .../schema-drift.json-column-parity.test.ts | 40 +- .../drivers/driver-sql/src/schema-drift.ts | 29 +- ...schema-drift.unbounded-text-column.test.ts | 5 +- ...er-11223-updatemany-write-coercion.test.ts | 4 +- ...-15683-temporal-text-operator-gate.test.ts | 36 +- ...43-multi-valued-boolean-membership.test.ts | 349 ++++++------- ...ulti-valued-boolean-read-inversion.test.ts | 465 +++++++----------- ...river-17590-json-column-membership.test.ts | 39 +- ...river-json-column-operator-refusal.test.ts | 7 +- ...sql-driver-target-field-provenance.test.ts | 7 +- packages/drivers/driver-sql/src/sql-driver.ts | 85 +++- .../src/data/value-roundtrip-conformance.ts | 24 +- 13 files changed, 530 insertions(+), 574 deletions(-) diff --git a/packages/drivers/driver-sql/src/schema-drift.base-type-mismatch.test.ts b/packages/drivers/driver-sql/src/schema-drift.base-type-mismatch.test.ts index 6207c9bd2e..d82ae1b08e 100644 --- a/packages/drivers/driver-sql/src/schema-drift.base-type-mismatch.test.ts +++ b/packages/drivers/driver-sql/src/schema-drift.base-type-mismatch.test.ts @@ -96,7 +96,10 @@ describe('diffManagedTable — multi-value field over a stale textual column (#1 }); it('fires on a stale TEXT column, not only varchar — a text column takes the stringified literal just as happily', () => { - const out = diffTags({ type: 'string', multiple: true }, staleColumn('text'), 'postgres'); + // [#17469] `lookup`, not `string`: "multi-valued" is `isMultiValueField` + // now, so only a multi-capable type reaches the json column by the + // `multiple` route — and `string` is not even an authorable FieldType. + const out = diffTags({ type: 'lookup', multiple: true }, staleColumn('text'), 'postgres'); expect(out).toHaveLength(1); expect(out[0].actual).toBe('text'); }); @@ -267,7 +270,7 @@ describe('diffManagedTable — multi-value field over a stale textual column (#1 // returns at its `multiple` branch before `maxLength` is read, so the // emitter never asks for that width and the differ must not either. for (const dialect of ['postgres', 'mysql'] as const) { - const out = diffTags({ type: 'string', multiple: true, maxLength: 50 }, staleColumn('character varying', 255), dialect); + const out = diffTags({ type: 'lookup', multiple: true, maxLength: 50 }, staleColumn('character varying', 255), dialect); expect(out.map((d) => d.op.type)).toEqual(['manual_column_type_change']); } }); @@ -484,7 +487,10 @@ describe('diffManagedTable — a SINGLE-VALUE JSON-class field over a stale text const TABLE = 'os11535_task'; const singleValueMeta = [{ name: TABLE, fields: { name: { type: 'string' }, tags: { type: 'string' } } }]; -const multiValueMeta = [{ name: TABLE, fields: { name: { type: 'string' }, tags: { type: 'string', multiple: true } } }]; +// [#17469] `tags` is a multi-valued LOOKUP, not a multi-valued `string`: the +// driver's storage decision derives from `isMultiValueField`, so the old +// spelling is a plain varchar column and would not exercise this fixture. +const multiValueMeta = [{ name: TABLE, fields: { name: { type: 'string' }, tags: { type: 'lookup', multiple: true } } }]; class DriftProbeDriver extends SqlDriver { /** @@ -558,7 +564,7 @@ function declareBaseTypeDriftSuite(cell: DialectCell): void { // stale rather than simply correct. const fresh = `${TABLE}_fresh`; await driver.execute(`drop table if exists ${fresh}`).catch(() => {}); - await driver.initObjects([{ name: fresh, fields: { tags: { type: 'string', multiple: true } } }] as any); + await driver.initObjects([{ name: fresh, fields: { tags: { type: 'lookup', multiple: true } } }] as any); const freshType = (await driver.columnsOf(fresh)).find((c) => c.name === 'tags')!.type; // [#12738] INVERTED on SQLite only, and the inversion REINFORCES this diff --git a/packages/drivers/driver-sql/src/schema-drift.json-column-parity.test.ts b/packages/drivers/driver-sql/src/schema-drift.json-column-parity.test.ts index 0ca5f70e12..d091ea85e0 100644 --- a/packages/drivers/driver-sql/src/schema-drift.json-column-parity.test.ts +++ b/packages/drivers/driver-sql/src/schema-drift.json-column-parity.test.ts @@ -57,7 +57,7 @@ */ import { describe, it, expect, afterEach } from 'vitest'; -import { FieldType, FILE_REFERENCE_TYPES } from '@objectstack/spec/data'; +import { FieldType, FILE_REFERENCE_TYPES, MULTI_CAPABLE_TYPES } from '@objectstack/spec/data'; import { SqlDriver } from './sql-driver.js'; import { diffManagedTable, JSON_COLUMN_FIELD_TYPES, type PhysicalColumn } from './schema-drift.js'; import { dialectCell } from './live-dialect-matrix.testkit.js'; @@ -93,6 +93,17 @@ const declaredOn = (moved: boolean): string[] => [...JSON_COLUMN_FIELD_TYPES, ...(moved ? [] : FILE_REFERENCE_TYPES)]; const MEDIA = [...FILE_REFERENCE_TYPES].sort(); +/** + * [#17469] The media family splits on `multiple` now. `file` / `image` are + * MULTI_CAPABLE_TYPES members, so `multiple: true` really does declare a list + * of ids there and the column is json on every deployment. `avatar` / `video` / + * `audio` are not — the 2026-09-13 ruling refuses the flag on them at the + * authoring entrance, and the driver's storage derives from the same predicate, + * so they are SINGLE-VALUE media columns and follow the ADR-0104 arm like any + * other. Both halves are read from the spec sets rather than listed. + */ +const MEDIA_MULTI_CAPABLE = MEDIA.filter((t) => MULTI_CAPABLE_TYPES.has(t)); +const MEDIA_SINGLE_ONLY = MEDIA.filter((t) => !MULTI_CAPABLE_TYPES.has(t)); describe('the JSON-class predicate the differ reads is the one the writer reads (#15771)', () => { let driver: WriterProbe; @@ -140,14 +151,24 @@ describe('the JSON-class predicate the differ reads is the one the writer reads expect(gained).toEqual([]); expect(MEDIA).toHaveLength(5); - // A `multiple: true` media field is a LIST of ids — a json column on - // every deployment, and the one member of the family the arm must NOT - // move. `createColumn` short-circuits on `multiple` above its type - // switch, so a driver that keyed the arm too high would break it here. - for (const type of MEDIA) { + // A MULTI-VALUED media field is a LIST of ids — a json column on every + // deployment, and the one shape of the family the arm must NOT move. + // `createColumn` short-circuits on the multi-value predicate above its + // type switch, so a driver that keyed the arm too high would break it + // here. [#17469] "Multi-valued" is `isMultiValueField`, so this holds for + // `file` / `image` — and NOT for `avatar` / `video` / `audio`, whose + // `multiple: true` the protocol refuses: those stay single-value media + // columns and move with the arm like every other member. + expect(MEDIA_MULTI_CAPABLE).toEqual(['file', 'image']); + expect(MEDIA_SINGLE_ONLY).toEqual(['audio', 'avatar', 'video']); + for (const type of MEDIA_MULTI_CAPABLE) { expect(unmoved.asksJson(type, { type, multiple: true }), type).toBe(true); expect(moved.asksJson(type, { type, multiple: true }), type).toBe(true); } + for (const type of MEDIA_SINGLE_ONLY) { + expect(unmoved.asksJson(type, { type, multiple: true }), type).toBe(true); + expect(moved.asksJson(type, { type, multiple: true }), type).toBe(false); + } // The differ moves with it, by name, over the same declarations. for (const type of MEDIA) { @@ -212,7 +233,12 @@ describe('the JSON-class predicate the differ reads is the one the writer reads // trues and real falses rather than passing over a uniform answer. expect(differReports({ type: 'file' }, moved)).toBe(!moved); expect(differReports({ type: 'file', multiple: true }, moved)).toBe(true); - expect(differReports({ type: 'string', multiple: true }, moved)).toBe(true); + // [#17469] A multi-valued LOOKUP is the `multiple`-only json route now — + // `string` is a driver alias the spec predicate does not recognise, so + // `{ type: 'string', multiple: true }` is a plain varchar column and is the + // negative control on the line below it. + expect(differReports({ type: 'lookup', multiple: true }, moved)).toBe(true); + expect(differReports({ type: 'string', multiple: true }, moved)).toBe(false); expect(differReports({ type: 'string' }, moved)).toBe(false); expect(differReports({ type: 'integer' }, moved)).toBe(false); }); diff --git a/packages/drivers/driver-sql/src/schema-drift.ts b/packages/drivers/driver-sql/src/schema-drift.ts index ba82910ebb..c4e7ea91ef 100644 --- a/packages/drivers/driver-sql/src/schema-drift.ts +++ b/packages/drivers/driver-sql/src/schema-drift.ts @@ -36,6 +36,7 @@ import { STRUCTURED_JSON_TYPES, FILE_REFERENCE_TYPES, MULTI_OPTION_TYPES, + isMultiValueField, } from '@objectstack/spec/data'; import type { SchemaDiffEntry } from '@objectstack/spec/shared'; @@ -542,10 +543,15 @@ export function physicalDefaultIsToken(raw: unknown, token: string): boolean { /** * Does this metadata field materialise a physical column? Mirrors * `SqlDriver.createColumn` exactly: `formula` is virtual (computed, no column); - * everything else — including `multiple` (a JSON column) — gets one. + * everything else — including a MULTI-VALUED field (a JSON column) — gets one. + * + * [#17469] "Multi-valued" is `@objectstack/spec`'s `isMultiValueField`, the + * same question `createColumn`'s short-circuit now asks. Reading `multiple` + * raw here would keep a column alive for a declaration the writer no longer + * gives one. */ export function fieldHasColumn(field: FieldDef): boolean { - if (field?.multiple) return true; + if (isMultiValueField({ type: String(field?.type ?? ''), multiple: field?.multiple === true })) return true; return (field?.type ?? 'string') !== 'formula'; } @@ -1068,16 +1074,23 @@ export function diffManagedTable(args: { // see {@link JSON_COLUMN_FIELD_TYPES}. `multiple: true` media is a list of // ids and stays a json column on every deployment, which the second // disjunct already covers on its own. + // [#17469] The `multiple` disjunct asks `@objectstack/spec`'s + // `isMultiValueField`, the predicate the writer now asks — a differ that + // read the flag raw would report a conversion to a column shape the + // platform would never create, which is the `⊆` direction this file's + // header calls out. + const declaresMultiValue = isMultiValueField({ type: declaredType, multiple: field.multiple === true }); const declaresJsonColumn = JSON_COLUMN_FIELD_TYPES.has(declaredType) - || field.multiple === true + || declaresMultiValue || (!fileColumnsMoved && FILE_REFERENCE_TYPES.has(declaredType)); - // Is the declared VALUE an array? `multiple: true` on any type, plus the + // Is the declared VALUE an array? A multi-valued field, plus the // inherently-multi option types, whose value is a list with or without the - // flag (`MULTI_OPTION_TYPES` — the spec's own class). This, and never - // JSON-class membership (which both populations share), is what decides - // whether the wrapping remedy is the right repair. - const declaresArray = field.multiple === true || MULTI_OPTION_TYPES.has(declaredType); + // flag (`MULTI_OPTION_TYPES` — the spec's own class, and already inside + // `isMultiValueField`). This, and never JSON-class membership (which both + // populations share), is what decides whether the wrapping remedy is the + // right repair. + const declaresArray = declaresMultiValue || MULTI_OPTION_TYPES.has(declaredType); if (declaresJsonColumn && jsonColumnTypeIsLoadBearing(dialect) && acceptsStringifiedJson(col.type)) { out.push({ kind: 'type_mismatch', diff --git a/packages/drivers/driver-sql/src/schema-drift.unbounded-text-column.test.ts b/packages/drivers/driver-sql/src/schema-drift.unbounded-text-column.test.ts index 724c91f40c..d7fc7ad744 100644 --- a/packages/drivers/driver-sql/src/schema-drift.unbounded-text-column.test.ts +++ b/packages/drivers/driver-sql/src/schema-drift.unbounded-text-column.test.ts @@ -167,7 +167,10 @@ describe('diffManagedTable — an unbounded text-family field over a pre-existin // A multi-value field is a `json` column whatever its element type would // have been, and the base-type branch (#11535) already owns that shape. // Reporting it here too would give one column two contradictory remedies. - const multi = diffBody({ type: 'signature', multiple: true }, staleColumn('varchar', 255)); + // [#17469] `image`, not `signature`: "multi-valued" is `isMultiValueField` + // now, and `signature` + `multiple: true` is refused at the authoring + // entrance — it is an ordinary unbounded-text column here, not a json one. + const multi = diffBody({ type: 'image', multiple: true }, staleColumn('varchar', 255)); expect(multi).toHaveLength(1); expect(multi[0].op.type).toBe('manual_column_type_change'); }); diff --git a/packages/drivers/driver-sql/src/sql-driver-11223-updatemany-write-coercion.test.ts b/packages/drivers/driver-sql/src/sql-driver-11223-updatemany-write-coercion.test.ts index 7be47cb19d..b40dfce5d9 100644 --- a/packages/drivers/driver-sql/src/sql-driver-11223-updatemany-write-coercion.test.ts +++ b/packages/drivers/driver-sql/src/sql-driver-11223-updatemany-write-coercion.test.ts @@ -136,7 +136,9 @@ function writeObject(name: string) { // without a length, and this is the filter target for every bulk write here. kind: { type: 'string' }, payload: { type: 'json' }, - tags: { type: 'string', multiple: true }, + // [#17469] `select`, not `string`: a JSON column by the one multi-value + // definition the writer and the deserializer now share. + tags: { type: 'select', multiple: true }, when: { type: 'datetime' }, day: { type: 'date' }, clock: { type: 'time' }, diff --git a/packages/drivers/driver-sql/src/sql-driver-15683-temporal-text-operator-gate.test.ts b/packages/drivers/driver-sql/src/sql-driver-15683-temporal-text-operator-gate.test.ts index ada8cca6fb..f68c1572a1 100644 --- a/packages/drivers/driver-sql/src/sql-driver-15683-temporal-text-operator-gate.test.ts +++ b/packages/drivers/driver-sql/src/sql-driver-15683-temporal-text-operator-gate.test.ts @@ -287,33 +287,45 @@ describe('[#15683] the per-dialect construct, compiled', () => { }); /** - * The carve-out this gate must NOT swallow. A `multiple: true` temporal field - * is stored as a JSON TEXT array, and there `$contains` is the MEMBERSHIP - * spelling — the one operator #7398 left working on a JSON column after - * refusing the equality family there. Gating it would turn a working - * membership filter into "matches nothing", which is the fail-CLOSED shape + * The carve-out this gate must NOT swallow: a JSON column's `$contains` is + * the MEMBERSHIP spelling — the one operator #7398 left working on a JSON + * column after refusing the equality family there. Gating it would turn a + * working membership filter into "matches nothing", the fail-CLOSED shape * #7398's own table calls out. Caught by that suite's live row when the gate * first landed without this condition; pinned here too, at the predicate, so * the two cannot drift apart. + * + * ⚠️ [#17469] The column carrying it used to be + * `{ type: 'datetime', multiple: true }`, and THAT SHAPE NO LONGER EXISTS. + * The maintainer ruling of 2026-09-13 gives "multi-valued" one definition — + * `isMultiValueField` — which `FieldSchema` enforces at the authoring + * entrance and the driver's storage now derives from, and `datetime` is not + * a multi-capable type. So a multi-valued TEMPORAL column is unauthorable, + * this gate's temporal limb can no longer meet one, and the carve-out is + * asserted where it still has a population: an ordinary JSON column. + * The second half pins the ruled change itself, so a revert of either side + * reddens this row rather than passing quietly. */ - it('a MULTI-VALUED temporal column keeps $contains — it is JSON membership, not a substring test', () => { + it('a JSON column keeps $contains — it is membership, not a substring test — and a `multiple` temporal column is no longer one', () => { class MultiProbeDriver extends CompilerProbeDriver { - declareMultiTemporal(): this { + declareMulti(): this { this.registerExternalObject({ name: TEMPORAL_OBJECT, fields: { label: { type: 'string' }, on_day: { type: 'date' }, - milestones: { type: 'datetime', multiple: true }, + milestones: { type: 'select', multiple: true }, + // [#17469] Declared, and deliberately NOT a JSON column any more. + stamps: { type: 'datetime', multiple: true }, }, }); return this; } } - const d = new MultiProbeDriver(DIALECTS[0][1]).declareMultiTemporal(); + const d = new MultiProbeDriver(DIALECTS[0][1]).declareMulti(); const membership = d.compileWhere({ milestones: { $contains: '2026-01-05T00:00:00.000Z' } }); expect(membership).not.toMatch(/1 = 0/); - // [#17590] This row's own title said "it is JSON membership, not a substring + // [#17590] This row's own title said "it is membership, not a substring // test" while the assertion under it named the SUBSTRING construct — the // only one that existed when it was written. It is a membership construct // now (`json_each` on this SQLite cell), so the assertion says what the @@ -324,6 +336,10 @@ describe('[#15683] the per-dialect construct, compiled', () => { // …while the scalar temporal column beside it is gated as usual, so this is // a carve-out for the JSON storage shape and not a hole in the gate. expect(d.compileWhere({ on_day: { $contains: '2026' } })).toMatch(/1 = 0/); + // [#17469] The ruled change, stated: `multiple: true` on a temporal type is + // NOT multi-valued, so the column is an ordinary declared datetime and the + // gate applies to it exactly as it does to the scalar beside it. + expect(d.compileWhere({ stamps: { $contains: '2026' } })).toMatch(/1 = 0/); }); it('the NON-temporal comparison operators over the same columns are untouched', () => { diff --git a/packages/drivers/driver-sql/src/sql-driver-17343-multi-valued-boolean-membership.test.ts b/packages/drivers/driver-sql/src/sql-driver-17343-multi-valued-boolean-membership.test.ts index c772663c82..a2cfa87ab3 100644 --- a/packages/drivers/driver-sql/src/sql-driver-17343-multi-valued-boolean-membership.test.ts +++ b/packages/drivers/driver-sql/src/sql-driver-17343-multi-valued-boolean-membership.test.ts @@ -1,104 +1,75 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. /** - * [#17343] A `multiple: true` BOOLEAN column keeps its `$contains` MEMBERSHIP - * filter — the carve-out #14079's declared-type gate never received on its - * boolean limb, while its numeric limb carried one from the first line it - * shipped and its temporal limb gained one in #15683. - * - * ## What was measured, and which of triage's two worlds this is + * [#17343 · retargeted by #17469] The declared-type gate never fires on a JSON + * column — and the population of JSON columns is now the one the protocol + * declares. * - * Triage fenced this card: establish WHY the type-gate excludes boolean before - * widening it — a deliberate exclusion (a stored array of booleans being - * genuinely meaningless) makes the honest fix a loud refusal at authoring - * time, not a silent `1 = 0`. The exclusion is an OMISSION, on four readings: + * ## What this file was filed for, and what happened to it * - * 1. **The predicate declared itself scalar-only.** As `isNonTextColumn` - * landed (`a646120d`, #14079) its own first line read "a declared numeric - * or boolean SCALAR?", and it annotated the numeric registry it reads as - * "`numericFields` (`NUMERIC_SCALAR_TYPES`, non-`multiple`)" — the author - * was tracking the `multiple` axis and recorded it on the limb where the - * registry happened to carry it. `booleanFields` carries no such condition - * and none was added, so the predicate's stated scope and its behaviour - * disagreed from the first commit. - * 2. **`booleanFields` is not a filter registry.** Its fill is commented for - * READ COERCION — "`toggle` shares boolean storage/affinity, so it needs - * the same read coercion (stored 1/0 → JS true/false)" — a question with no - * `multiple` axis in it. Nothing in either fill states a filter-side intent - * to include multi-valued columns. - * 3. **Nothing could have caught it.** #7398's live rows caught exactly this - * omission on the temporal limb when #15683 first landed without the - * condition; its fixture declares a `multiple: true` LOOKUP and a - * `multiple: true` DATETIME and no multi-valued boolean at all, so the - * boolean limb was never aimed at. - * 4. **The spec set says nothing about `multiple`.** `NON_TEXT_STORED_VALUE_TYPES` - * is keyed on the DECLARED TYPE; the carve-out for JSON storage is a driver - * concern each limb spells for itself. `boolean` + `multiple: true` is - * authorable — `FieldSchema.multiple` refuses exactly one type (`radio`) — - * and this driver already gives it a JSON column, a faithful array write and - * a working `$contains` on every OTHER multi-valued class. + * #17343: a `multiple: true` BOOLEAN column lost its `$contains` MEMBERSHIP + * filter — the carve-out #14079's declared-type gate never received on its + * boolean limb, while its numeric limb carried one from the first line it + * shipped and its temporal limb gained one in #15683. `{ FIELD: { $contains: + * 'true' } }` compiled `where 1 = 0` over a multi-valued boolean: the + * fail-CLOSED direction #7398's own table calls out, byte-identical to a filter + * that legitimately matched nothing. * - * So there is no refusal to make loud: the shape is declared, stored and - * filtered everywhere except here. The repair restores the membership filter. + * The card's fourth reading of WHY the exclusion existed was, verbatim: * - * ## The measurement, before (`origin/main` @ `f721ef0`) + * > `boolean` + `multiple: true` is authorable — `FieldSchema.multiple` refuses + * > exactly one type (`radio`) — and this driver already gives it a JSON + * > column, a faithful array write and a working `$contains` on every OTHER + * > multi-valued class. * - * `{ FIELD: { $contains: 'true' } }`, `better-sqlite3`, both registry fills: + * ⭐ **That premise is retired.** The maintainer ruling of 2026-09-13 (decision + * batch #128 item 5, option 1′, on #17469) gives "multi-valued" exactly ONE + * definition — `isMultiValueField` — refuses `multiple: true` at the authoring + * entrance on every type outside `MULTI_CAPABLE_TYPES` ∪ `MULTI_OPTION_TYPES`, + * and makes this driver's storage decision derive from that same predicate. So + * a multi-valued BOOLEAN, TOGGLE, NUMBER or DATETIME column is no longer + * authorable and is no longer a JSON column here: the declared-type gate fires + * on it exactly as it fires on the scalar beside it, which is correct, because + * the column now really does store one scalar. * - * | declared field | compiled WHERE | verdict | - * |:--|:--|:--| - * | `{ type: 'boolean', multiple: true }` | `where 1 = 0` | ⚠️ dead | - * | `{ type: 'toggle', multiple: true }` | `where 1 = 0` | ⚠️ dead | - * | `{ type: 'number', multiple: true }` | `` `nums` GLOB '*true*' `` | correct | - * | `{ type: 'tags' }` | `` `tags_` GLOB '*true*' ``| correct | + * ## What survives, and why this file is not vacuous * - * `1 = 0` is the fail-CLOSED direction #7398's own table calls out: the query - * returns nothing and is byte-identical to a filter that legitimately matched - * nothing, so an author reads "no matching records" and doubts their data. + * The invariant is unchanged and still has a population: **the declared-type + * gate never fires on a JSON column.** What moved is which declarations produce + * one. Every row below is asserted on a shape that exists after the ruling — + * a multi-valued `select` / `lookup`, and the inherently-multi `tags` — plus + * two pins of the ruling itself, so a revert on EITHER side reddens this file: * - * ## The invariant this file pins, one guard for the whole class + * 1. `FieldSchema` refuses the retired declarations (the entrance half); + * 2. the driver stops giving them a JSON column (the storage half). * - * **The declared-type gate never fires on a JSON column.** Swept over every - * member of `NON_TEXT_STORED_VALUE_TYPES` rather than over the two types this - * card names, so a class added to that set — or a registry that grows a fill - * without the carve-out — turns this file red instead of silently retiring - * another membership filter. + * ⛔ Do not restore a `{ type: 'boolean', multiple: true }` fixture to "keep the + * original cell". It would pin a branch the writer no longer has, and it would + * pass for the wrong reason: the gate fires, the answer is empty, and an empty + * answer is what the original defect looked like. * - * ## Which cells executed, and the one that cannot + * ## Which cells executed * - * - **sqlite** — always, embedded. The cell that carried the defect, so its - * rows are the reverse-verification witness. - * - **live mysql** — runs when provisioned; its `json` column is coerced for - * `LIKE`, so the membership rows answer there exactly as they do on SQLite. - * - **live postgres** — runs when provisioned and pins a NAMED DIVERGENCE - * instead of the answer. Its `json` column has no `LIKE` operator - * (SQLSTATE 42883), so the membership spelling is a `DATABASE_ERROR` 500 - * on that backend for EVERY multi-valued class, this card's included. - * ⚠️ Pre-existing and class-wide, not introduced here: measured on live - * PostgreSQL 16.13 with `sql-driver.ts` checked out at this branch's merge - * base, where the `multiple: true` NUMBER and `tags` columns answer the - * identical 42883 while the boolean column still answers the silent - * `1 = 0`. #17590 owns that ruling. What this card's repair changes on - * Postgres is only WHICH wrong answer the boolean cell gets — the silent - * one becomes the loud one every sibling class already gave. - * - Nothing in this repo had executed a text operator against a JSON column - * on a live server before this file: #7398's suite, which owns the - * membership spelling, constructs `better-sqlite3` in both of its - * fixtures. + * - **sqlite** — always, embedded. + * - **live mysql / live postgres** — run when provisioned. #17590's ruling + * (2026-09-12) replaced the text lowering with a real MEMBERSHIP construct + * compiled per dialect, so all three answer the same rows and this file + * carries no per-dialect branch. * - * @see SqlDriver.isNonTextColumn — the predicate; the boolean limb is the repair. - * @see SqlDriver.isJsonColumn — the carve-out's authority on "is this JSON". - * @see https://github.com/objectstack-ai/objectstack/issues/17590 (the live-Postgres membership gap) + * @see SqlDriver.isNonTextColumn — the predicate; its JSON carve-out is the invariant. + * @see SqlDriver.isJsonField — the storage half of the #17469 ruling. + * @see https://github.com/objectstack-ai/objectstack/issues/17469 (the ruling that retargeted this file) * @see https://github.com/objectstack-ai/objectstack/issues/17343 * @see https://github.com/objectstack-ai/objectstack/issues/14079 (the gate) - * @see https://github.com/objectstack-ai/objectstack/issues/15683 (the temporal carve-out this copies) + * @see https://github.com/objectstack-ai/objectstack/issues/15683 (the temporal carve-out) * @see https://github.com/objectstack-ai/objectstack/issues/7398 (the membership spelling it protects) + * @see https://github.com/objectstack-ai/objectstack/issues/17590 (the membership construct) */ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import type { Knex } from 'knex'; import type { DriverOptions, FilterCondition } from '@objectstack/spec/data'; -import { NON_TEXT_STORED_VALUE_TYPES } from '@objectstack/spec/data'; +import { FieldSchema, NON_TEXT_STORED_VALUE_TYPES } from '@objectstack/spec/data'; import { SqlDriver, type SqlDriverConfig } from './sql-driver.js'; import { DIALECT_CELLS, @@ -113,66 +84,64 @@ const MULTI_OBJECT = 'os17343_multi_boolean'; const BYPASS: DriverOptions = { bypassTenantAudit: true }; /** - * [#17590, CLOSED] The membership spelling now EXECUTES on every dialect, so - * this file no longer carries a per-dialect branch. + * The fixture shape, after #17469. * - * What stood here was a predicate excusing the PostgreSQL cell: `multiple: - * true` is a JSON column on every dialect, but only some of them let a TEXT - * operator reach it — SQLite stored the serialized array as TEXT so `GLOB - * '*x*'` was a membership test by accident, MySQL coerced its `json` column for - * `LIKE`, and PostgreSQL's `json` has no `LIKE` operator at all (SQLSTATE - * 42883 → `DATABASE_ERROR` 500). This card could only pin that divergence and - * name the issue that owned it. - * - * #17590's ruling (2026-09-12) replaced the text lowering with a real - * MEMBERSHIP construct compiled PER DIALECT, so all three now answer the same - * rows — including the boolean cell this card owns, which is what "the boolean - * cell stays loud until the construct covers it" was waiting for. The rows - * below are therefore asserted on every cell, with no branch to be honest - * about. - * - * ⚠️ Consequence for THIS file's construct assertions: `$contains` on a - * multi-valued column no longer compiles a pattern match on any dialect. What - * this card is about survives unchanged — the declared-type gate must not fire - * on a JSON column — so those assertions now read "a real predicate, not the - * declared constant", with the shape of that predicate owned by - * `sql-driver-17590-json-column-membership.test.ts`. - */ - -/** - * The fixture shape. `flags`/`toggles` are the cell this card owns; `nums` and - * `tags_` are the POSITIVE CONTROLS the card names (they compile a real pattern - * match today and must not move); `scalar_flag`/`scalar_toggle` are the - * NEGATIVE controls — the gate must still fire on them, or the repair is a hole - * in the gate rather than a carve-out for the JSON storage shape. + * `picks` / `refs` are multi-valued by `isMultiValueField` and therefore JSON + * columns — the cell the invariant now owns. `tags_` is the inherently-multi + * option type beside them. `scalar_flag` / `scalar_toggle` are the NEGATIVE + * controls: the gate must still fire on them, or the carve-out is a hole in the + * gate rather than a reading of the storage shape. `retired_flags` is the + * RULING's own pin — the declaration the entrance refuses, which reaches this + * driver only through a hand-built fixture like this one and is a plain boolean + * column when it does. */ const MULTI_FIELDS: Record> = { label: { type: 'string' }, - flags: { type: 'boolean', multiple: true }, - toggles: { type: 'toggle', multiple: true }, - nums: { type: 'number', multiple: true }, + picks: { type: 'select', multiple: true }, + refs: { type: 'lookup', multiple: true }, tags_: { type: 'tags' }, scalar_flag: { type: 'boolean' }, scalar_toggle: { type: 'toggle' }, + retired_flags: { type: 'boolean', multiple: true }, }; /** - * Rows chosen so the membership filter has a real job: `true` is a member of - * row 1's and row 3's `flags` and NOT of row 2's, so a gate that silently - * fails to fire returns a WRONG set rather than the same empty list a fired - * gate returns. + * Rows chosen so the membership filter has a real job: `red` is a member of row + * 1's and row 3's `tags_` and NOT of row 2's, so a gate that silently fails to + * fire returns a WRONG set rather than the same empty list a fired gate returns. */ const MULTI_ROWS = [ - { id: '1', label: 'alpha', flags: [true, false], toggles: [true], nums: [1, 2], tags_: ['red'] }, - { id: '2', label: 'beta', flags: [false], toggles: [false], nums: [3], tags_: ['blue'] }, - { id: '3', label: 'gamma', flags: [true], toggles: [true, false], nums: [1], tags_: ['red', 'blue'] }, + { id: '1', label: 'alpha', picks: ['a', 'b'], refs: ['r1', 'r2'], tags_: ['red'], scalar_flag: true, scalar_toggle: true, retired_flags: true }, + { id: '2', label: 'beta', picks: ['c'], refs: ['r3'], tags_: ['blue'], scalar_flag: false, scalar_toggle: false, retired_flags: false }, + { id: '3', label: 'gamma', picks: ['a'], refs: ['r1'], tags_: ['red', 'blue'], scalar_flag: true, scalar_toggle: false, retired_flags: true }, ] as const; const POSITIVE_OPERATORS = ['$contains', '$startsWith', '$endsWith', '$icontains', '$like', '$ilike'] as const; +/** The declarations #17469 retired, read from the spec sets rather than listed. */ +const RETIRED_MULTI_TYPES = [...NON_TEXT_STORED_VALUE_TYPES].sort(); + +describe('[#17469] the entrance half — the declarations this file used to pin are REFUSED', () => { + it('`FieldSchema` refuses `multiple: true` on every declared non-text class', () => { + expect(RETIRED_MULTI_TYPES.length, 'the swept population').toBeGreaterThan(8); + for (const type of RETIRED_MULTI_TYPES) { + const r = FieldSchema.safeParse({ name: 'several', type, multiple: true }); + expect(r.success, `\`${type}\` + multiple: true must be refused at the entrance`).toBe(false); + } + }); + + it('…and still accepts the multi-capable declarations this file now uses — the negative control', () => { + expect(FieldSchema.safeParse({ name: 'refs', type: 'lookup', reference: 'account', multiple: true }).success).toBe(true); + expect(FieldSchema.safeParse({ + name: 'picks', type: 'select', multiple: true, + options: [{ label: 'A', value: 'a' }, { label: 'B', value: 'b' }], + }).success).toBe(true); + }); +}); + for (const cell of DIALECT_CELLS) { if (!cell.available) { - declareUnprovisionedCell(cell, '[#17343] the multi-valued boolean membership filter'); + declareUnprovisionedCell(cell, '[#17343] the JSON-column membership filter'); continue; } declareMembershipSweep(cell); @@ -185,7 +154,7 @@ for (const cell of DIALECT_CELLS) { * ANSWERS, rather than merely compiling to something other than a constant. */ function declareMembershipSweep(cell: DialectCell): void { - describe(`[#17343] SqlDriver — $contains over a multi-valued boolean column (${cell.label})`, () => { + describe(`[#17343] SqlDriver — $contains over a JSON column (${cell.label})`, () => { let driver: SqlDriver; let knexInstance: Knex; @@ -217,71 +186,34 @@ function declareMembershipSweep(cell: DialectCell): void { expect(rows.map((r) => String(r.id)).sort()).toEqual(['1', '2', '3']); }); - /** - * The stored form, read raw, on the dialect whose storage makes the - * pattern match a MEMBERSHIP test: the cell holds the serialized array as - * TEXT, so the match is over `[true,false]` and selects the rows whose - * array really carries `true`. Without this, a green membership row could - * be a pattern accidentally matching something else entirely. - * - * ⚠️ SQLite-only deliberately, and the reason outlived #17590: on - * PostgreSQL and MySQL the same declaration produces a real `json` column, - * so `typeof()` has nothing to say there. What the membership filter is - * asking on each backend is now the same question either way — see - * `jsonMembershipPredicate`. - */ - if (cell.id === 'sqlite') { - it('the column really holds the JSON array text — so the matched rows below are MEMBERSHIP', async () => { - const probe = (await knexInstance.raw( - `select typeof(flags) as t_flags, flags as raw_flags from ${MULTI_OBJECT} where id = '1'`, - )) as Array>; - const row = (Array.isArray(probe) ? probe[0] : (probe as { rows?: Array> }).rows?.[0])!; - expect(row.t_flags).toBe('text'); - expect(String(row.raw_flags)).toContain('true'); - expect(String(row.raw_flags)).toContain('false'); - }); - } - - it('$contains over a multiple:true BOOLEAN answers the rows whose array holds that member', async () => { - expect(await ids({ flags: { $contains: 'true' } })).toEqual(['1', '3']); - expect(await ids({ flags: { $contains: 'false' } })).toEqual(['1', '2']); + it('$contains over a multi-valued SELECT answers the rows whose array holds that member', async () => { + expect(await ids({ picks: { $contains: 'a' } })).toEqual(['1', '3']); + expect(await ids({ picks: { $contains: 'c' } })).toEqual(['2']); }); - it('$contains over a multiple:true TOGGLE answers identically — same registry arm', async () => { - expect(await ids({ toggles: { $contains: 'true' } })).toEqual(['1', '3']); - expect(await ids({ toggles: { $contains: 'false' } })).toEqual(['2', '3']); + it('$contains over a multi-valued LOOKUP answers identically — same storage shape', async () => { + expect(await ids({ refs: { $contains: 'r1' } })).toEqual(['1', '3']); + expect(await ids({ refs: { $contains: 'r3' } })).toEqual(['2']); }); - /** - * The card's positive controls: these two already worked and the repair - * must not move them. - */ - it('the multiple:true NUMBER and the tags column beside them are unmoved', async () => { - expect(await ids({ nums: { $contains: '1' } })).toEqual(['1', '3']); + it('the inherently-multi `tags` column beside them is unmoved', async () => { expect(await ids({ tags_: { $contains: 'red' } })).toEqual(['1', '3']); + expect(await ids({ tags_: { $contains: 'blue' } })).toEqual(['2', '3']); }); - /** - * [#17590] Where the NAMED DIVERGENCE used to be pinned. It said: on - * PostgreSQL the column is a real `json` column and `LIKE` has no operator - * over `json`, so the membership spelling raised SQLSTATE 42883 — a - * `DATABASE_ERROR` 500 — for EVERY multi-valued class, this card's boolean - * cell included. It was pinned rather than skipped precisely so that "the - * day #17590 is ruled and the membership filter starts answering here, this - * block goes red and whoever fixes it must come and delete it". It went - * red, and this is that deletion: the rows above now run on the PostgreSQL - * cell like any other, and the construct that makes them run is pinned in - * `sql-driver-17590-json-column-membership.test.ts`. - */ - /** * The negative control, and the reason this is a carve-out rather than a * hole. A SCALAR boolean is still a declared non-text column, so every * positive text operator still answers the declared no-match and * `$notContains` its exact complement. + * + * ⭐ [#17469] `retired_flags` is in the same loop on purpose: a `boolean` + * carrying `multiple: true` is NOT multi-valued any more, so it is a plain + * boolean column and the gate fires on it identically. That is the storage + * half of the ruling, asserted where it is observable. */ - it('the SCALAR boolean and toggle beside them are STILL gated — the carve-out is per storage shape', async () => { - for (const field of ['scalar_flag', 'scalar_toggle']) { + it('the SCALAR boolean/toggle — and the RETIRED multi-valued boolean — are gated alike', async () => { + for (const field of ['scalar_flag', 'scalar_toggle', 'retired_flags']) { for (const op of POSITIVE_OPERATORS) { expect(await ids({ [field]: { [op]: 'true' } } as FilterCondition), `${op} over ${field}`).toEqual([]); } @@ -332,11 +264,11 @@ describe('[#17343] the per-dialect construct, compiled — the registerExternalO }; for (const [label, config] of DIALECTS) { - it(`${label}: a multi-valued boolean/toggle compiles a real predicate, never the constant`, () => { + it(`${label}: a multi-valued select/lookup compiles a real predicate, never the constant`, () => { const d = typed(config); - for (const field of ['flags', 'toggles']) { + for (const field of ['picks', 'refs']) { for (const op of POSITIVE_OPERATORS) { - const sql = d.compileWhere({ [field]: { [op]: 'true' } } as FilterCondition); + const sql = d.compileWhere({ [field]: { [op]: 'x' } } as FilterCondition); expect(sql, `${op} over ${field}`).not.toMatch(/1 = 0|1 = 1/); // [#17590] `$contains` compiles the MEMBERSHIP construct now and the // rest of the family still compiles a pattern match. What this card @@ -348,9 +280,9 @@ describe('[#17343] the per-dialect construct, compiled — the registerExternalO } }); - it(`${label}: the scalar boolean/toggle still compile to the declared constants`, () => { + it(`${label}: the scalar boolean/toggle — and the retired multi-valued one — compile the declared constants`, () => { const d = typed(config); - for (const field of ['scalar_flag', 'scalar_toggle']) { + for (const field of ['scalar_flag', 'scalar_toggle', 'retired_flags']) { for (const op of POSITIVE_OPERATORS) { const sql = d.compileWhere({ [field]: { [op]: 'true' } } as FilterCondition); expect(sql, `${op} over ${field}`).toMatch(/where 1 = 0/); @@ -361,15 +293,15 @@ describe('[#17343] the per-dialect construct, compiled — the registerExternalO } }); - it(`${label}: the card's positive controls still compile a real predicate`, () => { + it(`${label}: the positive controls still compile a real predicate`, () => { const d = typed(config); - for (const field of ['nums', 'tags_', 'label']) { - expect(d.compileWhere({ [field]: { $contains: 'true' } } as FilterCondition), field) + for (const field of ['tags_', 'label']) { + expect(d.compileWhere({ [field]: { $contains: 'red' } } as FilterCondition), field) .toMatch(REAL_PREDICATE[label]!); } - // …and the SCALAR column among them is the one still on the pattern + // …and the SCALAR string column among them is the one still on the pattern // emitter, which is what keeps the row above from passing vacuously. - expect(d.compileWhere({ label: { $contains: 'true' } } as FilterCondition)).toMatch(/LIKE|GLOB/); + expect(d.compileWhere({ label: { $contains: 'red' } } as FilterCondition)).toMatch(/LIKE|GLOB/); }); } @@ -385,12 +317,12 @@ describe('[#17343] the per-dialect construct, compiled — the registerExternalO await managed.getKnex().schema.dropTableIfExists(MULTI_OBJECT); await managed.initObjects([{ name: MULTI_OBJECT, fields: MULTI_FIELDS } as never]); try { - for (const field of ['flags', 'toggles', 'nums', 'scalar_flag']) { - const filter = { [field]: { $contains: 'true' } } as FilterCondition; + for (const field of ['picks', 'refs', 'tags_', 'scalar_flag', 'retired_flags']) { + const filter = { [field]: { $contains: 'a' } } as FilterCondition; expect(managed.compileWhere(filter), field).toBe(external.compileWhere(filter)); } // …and the agreed construct is the working one, not an agreed `1 = 0`. - expect(managed.compileWhere({ flags: { $contains: 'true' } })).toMatch(REAL_PREDICATE.sqlite!); + expect(managed.compileWhere({ picks: { $contains: 'a' } })).toMatch(REAL_PREDICATE.sqlite!); } finally { await managed.getKnex().schema.dropTableIfExists(MULTI_OBJECT).catch(() => {}); await managed.disconnect?.(); @@ -399,32 +331,43 @@ describe('[#17343] the per-dialect construct, compiled — the registerExternalO /** * ONE GUARD FOR THE WHOLE CLASS — the invariant behind all three limbs of the - * predicate rather than the two types this card names. + * predicate rather than the two types this card named. + * + * A JSON column's `$contains` is the MEMBERSHIP spelling #7398 deliberately + * preserved, never a substring test over a stored scalar, which is the only + * thing the declared-type gate is about. So the gate must not fire on ANY + * JSON column. * - * `multiple: true` makes any column a JSON TEXT array ({@link - * SqlDriver.isJsonField}), and on a JSON array `$contains` is the MEMBERSHIP - * spelling #7398 deliberately preserved — never a substring test over a - * stored scalar, which is the only thing the declared-type gate is about. So - * the gate must not fire on ANY multi-valued column, whatever its declared - * class. The numeric limb spells that at its registry, the temporal and - * boolean limbs at the predicate; this sweep is what makes a fourth class - * joining the set without a carve-out red on arrival instead of silent. + * ⭐ [#17469] The sweep runs in BOTH directions now, and the second direction + * is the ruling: a declared non-text class carrying `multiple: true` is NOT + * multi-valued, so it is an ordinary scalar column and the gate DOES fire on + * it. Before the ruling this row asserted the opposite, over a declaration + * `FieldSchema` now refuses. */ - it('NO declared non-text class fires the gate once the column is MULTI-VALUED', () => { - const classes = [...NON_TEXT_STORED_VALUE_TYPES].sort(); + it('the gate never fires on a JSON column — and DOES fire on a retired `multiple` non-text class', () => { + const classes = RETIRED_MULTI_TYPES; expect(classes.length, 'the swept population — a class added upstream must reach this sweep').toBeGreaterThan(8); for (const declared of classes) { const d = new CompilerProbeDriver(DIALECTS[0][1]).declareMulti({ many: { type: declared, multiple: true }, one: { type: declared }, }); - const membership = d.compileWhere({ many: { $contains: 'x' } }); - expect(membership, `multiple:true ${declared}`).not.toMatch(/1 = 0/); - // [#17590] the membership construct on the SQLite cell this sweep runs on. - expect(membership, `multiple:true ${declared}`).toMatch(REAL_PREDICATE.sqlite!); - // …while the SCALAR column of the same class is gated, which is what - // makes the row above a carve-out reading rather than a dead gate. + // The ruled storage change: `multiple` on this class declares nothing, so + // both columns are scalars of the same declared class and both are gated. + expect(d.compileWhere({ many: { $contains: 'x' } }), `multiple:true ${declared}`).toMatch(/1 = 0/); expect(d.compileWhere({ one: { $contains: 'x' } }), `scalar ${declared}`).toMatch(/1 = 0/); } + // …while a JSON column of every shape that still produces one is NOT gated, + // which is what keeps the rows above from reading as a dead gate. + const json = new CompilerProbeDriver(DIALECTS[0][1]).declareMulti({ + sel: { type: 'select', multiple: true }, + look: { type: 'lookup', multiple: true }, + usr: { type: 'user', multiple: true }, + checks: { type: 'checkboxes' }, + blob: { type: 'json' }, + }); + for (const field of ['sel', 'look', 'usr', 'checks', 'blob']) { + expect(json.compileWhere({ [field]: { $contains: 'x' } } as FilterCondition), field).not.toMatch(/1 = 0/); + } }); }); diff --git a/packages/drivers/driver-sql/src/sql-driver-17586-multi-valued-boolean-read-inversion.test.ts b/packages/drivers/driver-sql/src/sql-driver-17586-multi-valued-boolean-read-inversion.test.ts index eb5ea97f4f..e0d5d4482e 100644 --- a/packages/drivers/driver-sql/src/sql-driver-17586-multi-valued-boolean-read-inversion.test.ts +++ b/packages/drivers/driver-sql/src/sql-driver-17586-multi-valued-boolean-read-inversion.test.ts @@ -1,118 +1,82 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. /** - * [#17586] A `multiple: true` BOOLEAN / TOGGLE column stops presenting its - * stored array as a single INVERTED `true`. + * [#17586 · retargeted by #17469] A JSON column's stored array survives the + * read — and `booleanFields` never contains one. * - * ## The failure this file exists to pin + * ## What this file was filed for * - * `formatOutput` runs its `jsonFields` pass first, which `JSON.parse`s the - * cell into a real array, and then its `booleanFields` pass does - * `data[field] = Boolean(data[field])`. Every non-empty array is truthy, so - * the presented value is `true` whatever the array holds: + * `formatOutput` runs its `jsonFields` pass first, which `JSON.parse`s the cell + * into a real array, and then its `booleanFields` pass does + * `data[field] = Boolean(data[field])`. Every non-empty array is truthy, so the + * presented value was `true` whatever the array held — and a stored `[false]` + * presenting as `true` is not a mis-SHAPED answer, it is the OPPOSITE of what + * is stored, with no error anywhere. The repair was at the REGISTRY: narrow + * both fills so a multi-valued column is never in `booleanFields`, which moves + * all four readers of that registry at once. * - * | declared field | written | stored cell | read back (before) | - * |:--|:--|:--|:--| - * | `{ type: 'boolean', multiple: true }` | `[true, false]`| `[true,false]`| `true` ⚠️ array gone | - * | `{ type: 'toggle', multiple: true }` | `[false]` | `[false]` | `true` ⚠️ **INVERTED** | - * | `{ type: 'number', multiple: true }` | `[1, 2]` | `[1,2]` | `[1, 2]` correct | - * | `{ type: 'tags' }` | `['x']` | `["x"]` | `['x']` correct | + * ## ⭐ What the #17469 ruling did to it * - * ⭐ The `toggle` row is the whole card. A stored `[false]` presenting as - * `true` is not a mis-SHAPED answer, it is the OPPOSITE of what is stored, - * with no error anywhere — so this file's central assertion is that - * `[false]` does not read back as `true`, not merely that it is "no longer a - * single boolean". A test that only proved the latter would stay green on a - * repair that presented `false` for `[true]`. + * The card's cell was `{ type: 'boolean', multiple: true }` — a shape whose + * existence rested on `FieldSchema.multiple` refusing exactly one type + * (`radio`). The maintainer ruling of 2026-09-13 (decision batch #128 item 5, + * option 1′) gives "multi-valued" ONE definition — `isMultiValueField` — + * refuses `multiple: true` at the authoring entrance on every type outside + * `MULTI_CAPABLE_TYPES` ∪ `MULTI_OPTION_TYPES`, and derives this driver's + * storage decision from it. A `boolean` / `toggle` column therefore **cannot be + * a JSON column at all** any more, which is a stronger guarantee than the + * registry carve-out was: the collapse has no reachable input. * - * ## The repair, and why it is at the REGISTRY and not at a reader + * So the registry rule is restated in the terms that survive — **the two + * registries partition the columns: a JSON column is never in `booleanFields`, + * and a scalar boolean always is** — and asserted over the multi-valued shapes + * that still exist (`select` / `lookup` flagged `multiple: true`, and the + * inherently-multi `tags`). Two further rows pin the ruling itself, one per + * half, so a revert on EITHER side reddens this file: * - * `&& !field.multiple` is the house spelling of both fills, already written - * three times in each block (`mediaCols`, `numericCols`, `numericValueCols`); - * `booleanCols.push(name)` was the single omission, in BOTH fills - * (`registerExternalObject` and `registerManagedObjectMetadata`) — a repair to - * one leaves the other live. + * 1. `FieldSchema` refuses `boolean` / `toggle` + `multiple: true`; + * 2. the driver gives that declaration a plain boolean column and registers + * it in `booleanFields` — registry and storage agreeing, which is exactly + * the invariant the original repair was reaching for. * - * Narrowing the registry moves every reader of `booleanFields` at once, so the - * card fenced the round on enumerating them first. All four read sites, and - * what each does for a multi-valued column: - * - * 1. **The #11635 Postgres aggregate cast** (`aggregate()`) — gated - * `isPostgres && booleanFields[table].includes(fieldExpr)`, emits - * `cast(?? as int)`. A `multiple: true` boolean is a JSON column on every - * dialect ({@link SqlDriver.isJsonField}), and `cast(json as int)` is not a - * defined cast on Postgres — so the registry entry bought this reader a - * cast it must not emit. ⇒ does NOT need the column. ⭐ MEASURED on live - * PostgreSQL 16.13 by reading the statements the server received: with the - * guard the door emits `max("flags")`, without it `max(cast("flags" as - * int))` — see the reader-1 row in the live-postgres block below. - * 2. **`readPresentationKind`** (the `aggregate()` / `distinct()` doors) — - * gated `(isSqlite || isMysql) && booleanFields[table].includes(field)`, - * returns `'boolean'`, whose presenter is the same `Boolean(v)`. On those - * doors the stored cell arrives as the raw JSON **string** `'[false]'`, and - * `Boolean('[false]')` is `true` — the identical inversion, one door over. - * ⇒ does NOT need the column; it is actively harmed by it. - * 3. **`formatOutput`'s row pass** — the defect itself. ⇒ does NOT need it. - * 4. **`isNonTextColumn`** (#14079/#15683/#17343's declared-type gate) — the - * one reader that already carved out multi-valued columns AT THE READER, - * spelled `booleanFields[table].includes(f) && !this.isJsonColumn(table, f)`. - * ⇒ does NOT need the column either, and the narrowing is *behaviour- - * identical* there rather than merely safe: for a `boolean`/`toggle` field - * `isJsonField` reduces to `JSON_COLUMN_TYPES.has(type) || !!field.multiple` - * — and neither type is in `JSON_COLUMN_TYPES` — so `isJsonColumn` on this - * class is exactly `!!field.multiple`, the same predicate the fills now - * apply. `§ the four readers` below pins that equivalence by execution. - * - * ⇒ no reader needs a multi-valued column in `booleanFields`; three of the - * four are repaired by its absence and the fourth cannot observe it. The - * carve-out therefore belongs at the registry, which is also where its three - * neighbours already spell it. + * ⛔ Do not restore a `{ type: 'boolean', multiple: true }` array fixture to + * "keep the original cell". The column is a boolean column now; writing an + * array into it is not a test of this file's subject. * * ## Controls * - * - **Positive** (must not move): the `multiple: true` NUMBER row and the - * `tags` row — both correct before this change, per the card's own table. - * - **Negative** (the repair must not become a hole): a SCALAR `boolean` / + * - **Positive** (must not move): the `tags` row — correct before the original + * change, per the card's own table. + * - **Negative** (the rule must not become a hole): a SCALAR `boolean` / * `toggle` still takes the read coercion it exists for — stored `1`/`0` on * SQLite and `tinyint(1)` on MySQL presented as JS `true`/`false` (#11782). - * Narrowing by `!field.multiple` must not cost that. * * ## Which cells execute, and the one door that cannot * - * - **sqlite** — always, embedded. The cell that carried the defect, so its - * rows are the reverse-verification witness (13 red before the guard, all - * green after). - * - **live postgres** — runs when provisioned. Every ROW-read row above - * answers here exactly as it does on SQLite, because `formatOutput`'s - * boolean pass was always gated `isSqlite || isMysql` and so never reached - * this dialect. Its `distinct()` door is the exception and is pinned as a - * NAMED DIVERGENCE instead of an answer — see {@link distinctExecutes}, - * which carries the two-leg measurement proving the divergence is - * class-wide and predates this change. + * - **sqlite** — always, embedded. + * - **live postgres** — runs when provisioned. Every ROW-read row answers + * here exactly as it does on SQLite, because `formatOutput`'s boolean pass + * was always gated `isSqlite || isMysql`. Its `distinct()` door is the + * exception and is pinned as a NAMED DIVERGENCE — see + * {@link distinctExecutes}. * - **live mysql** — runs when provisioned; it takes the same coercion gate * as SQLite, so its rows answer identically. * - * ⚠️ The whole driver-sql suite was run against a live PostgreSQL 16.13 under - * CI's own configuration (server `Asia/Shanghai`, process `TZ=America/New_York`) - * to confirm this file is the only thing that moves: `179 passed | 3 skipped`, - * zero failures. - * * @see SqlDriver.formatOutput — the row-read pass the inversion lived in. * @see SqlDriver.readPresentationKind — the `aggregate()`/`distinct()` door. * @see SqlDriver.isNonTextColumn — the reader that carves out at the reader. + * @see SqlDriver.isJsonField — the storage half of the #17469 ruling. + * @see https://github.com/objectstack-ai/objectstack/issues/17469 (the ruling that retargeted this file) * @see https://github.com/objectstack-ai/objectstack/issues/17586 * @see https://github.com/objectstack-ai/objectstack/issues/17343 (the filter half) * @see https://github.com/objectstack-ai/objectstack/issues/11782 (the pass) * @see https://github.com/objectstack-ai/objectstack/issues/11635 (the PG cast) - * @see https://github.com/objectstack-ai/objectstack/issues/17639 (the missing - * ADR-0112 envelope on the `distinct()` door, measured by this round) - * @see https://github.com/objectstack-ai/objectstack/issues/17590 (the sibling - * `LIKE`-over-`json` divergence on the filter side) */ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import type { Knex } from 'knex'; import type { DriverOptions } from '@objectstack/spec/data'; +import { FieldSchema } from '@objectstack/spec/data'; import { SqlDriver, type SqlDriverConfig } from './sql-driver.js'; import { DIALECT_CELLS, @@ -126,93 +90,79 @@ const READ_OBJECT = 'os17586_multi_boolean_read'; /** Diagnostics-only; it never changes which rows a read touches. */ const BYPASS: DriverOptions = { bypassTenantAudit: true }; -/** The shape a thrown driver error carries at this door. */ -interface WireBearingError extends Error { - code?: string; - status?: number; -} - -/** - * The SQLSTATE the BACKEND raised, read off the envelope's non-enumerable - * `cause` — where [#17639] put it when it brought the ADR-0112 terminal to - * this door. Before that card the same value was the caller-visible `code`. - */ -const sqlstateOf = (err: WireBearingError): string | undefined => - (err as { cause?: { code?: string } }).cause?.code; - /** * Can `distinct()` EXECUTE over a JSON column on this backend? * - * `multiple: true` is a JSON column on every dialect, but PostgreSQL's `json` - * type defines no equality operator, and `SELECT DISTINCT` needs one — so the - * statement is refused before any row is presented: - * - * ``` - * select distinct j from t; - * ERROR: could not identify an equality operator for type json - * ``` - * - * ⚠️ This is a property of the COLUMN CLASS, not of this card's cell, and not - * of this card's change. Measured on live PostgreSQL 16.13, two legs, with the - * fixture's `multiple: true` NUMBER and `tags` columns — neither of which was - * ever in `booleanFields`, so no part of this change can reach them — failing - * identically to the boolean cell, and with a SCALAR boolean (a real `boolean` - * column, not `json`) answering normally in the same run: - * - * | leg | `sql-driver.ts` blob | toggles / flags / nums / tags_ | scalar_flag | - * |:--|:--|:--|:--| - * | change present | `f7fe22f8` | all four raise SQLSTATE 42883 | `[false]` | - * | change reverted | `a2b37dc6` (= merge base, verified on disk by `git hash-object`) | all four raise SQLSTATE 42883 | `[false]` | - * - * ⇒ the same failure, byte for byte, on a tree with no part of this change on - * it. The mirror of the `LIKE`-over-`json` divergence #17590 owns on the filter - * side, reached through the read door instead. + * A JSON column is a JSON column on every dialect, but PostgreSQL's `json` type + * defines no equality operator, and `SELECT DISTINCT` needs one — so the + * statement is refused before any row is presented (`could not identify an + * equality operator for type json`). A property of the COLUMN CLASS, measured + * on live PostgreSQL 16.13 across both legs of the original card's change and + * unchanged by it — the mirror of the `LIKE`-over-`json` divergence #17590 owns + * on the filter side, reached through the read door instead. */ const distinctExecutes = (cell: DialectCell): boolean => cell.id !== 'pg'; /** - * The card's fixture, plus the two scalar negative controls. `flags`/`toggles` - * are the cell this card owns; `nums`/`tags_` are the positive controls the - * card names; `scalar_flag`/`scalar_toggle` prove the read coercion the - * registry exists for survives the narrowing. + * The fixture, after #17469. + * + * `picks` / `refs` are multi-valued by `isMultiValueField` and therefore JSON + * columns — the cell this file's rule now owns. `tags_` is the positive + * control. `scalar_flag` / `scalar_toggle` prove the read coercion the registry + * exists for is untouched. `retired_flags` is the RULING's own pin: the + * declaration the entrance refuses, which reaches this driver only through a + * hand-built fixture like this one and is a plain boolean column when it does. */ const READ_FIELDS: Record> = { label: { type: 'string' }, - flags: { type: 'boolean', multiple: true }, - toggles: { type: 'toggle', multiple: true }, - nums: { type: 'number', multiple: true }, + picks: { type: 'select', multiple: true }, + refs: { type: 'lookup', multiple: true }, tags_: { type: 'tags' }, scalar_flag: { type: 'boolean' }, scalar_toggle: { type: 'toggle' }, + retired_flags: { type: 'boolean', multiple: true }, }; /** - * Row 2 is the card's starred case: `toggles: [false]` — an array whose only - * member is `false`, stored faithfully, presented as `true` before the repair. - * Row 3's `flags: [false, false]` is the same failure one width over, so a - * repair that special-cased a single-element array cannot pass. + * Row 2's single-member arrays are the shape a repair that special-cased array + * width would get wrong; row 3's two-member arrays are the same read one width + * over. */ const READ_ROWS = [ { id: '1', label: 'alpha', - flags: [true, false], toggles: [true], nums: [1, 2], tags_: ['red'], - scalar_flag: true, scalar_toggle: false, + picks: ['a', 'b'], refs: ['r1', 'r2'], tags_: ['red'], + scalar_flag: true, scalar_toggle: false, retired_flags: true, }, { id: '2', label: 'beta', - flags: [false], toggles: [false], nums: [3], tags_: ['blue'], - scalar_flag: false, scalar_toggle: true, + picks: ['c'], refs: ['r3'], tags_: ['blue'], + scalar_flag: false, scalar_toggle: true, retired_flags: false, }, { id: '3', label: 'gamma', - flags: [false, false], toggles: [true, false], nums: [1], tags_: ['red', 'blue'], - scalar_flag: true, scalar_toggle: true, + picks: ['a', 'c'], refs: ['r1'], tags_: ['red', 'blue'], + scalar_flag: true, scalar_toggle: true, retired_flags: false, }, ] as const; +describe('[#17469] the entrance half — the declaration this file was filed about is REFUSED', () => { + it('`FieldSchema` refuses `boolean` / `toggle` + `multiple: true`', () => { + for (const type of ['boolean', 'toggle']) { + const r = FieldSchema.safeParse({ name: 'flags', type, multiple: true }); + expect(r.success, `\`${type}\` + multiple: true must be refused at the entrance`).toBe(false); + } + }); + + it('…and still accepts the multi-capable declarations this file now uses — the negative control', () => { + expect(FieldSchema.safeParse({ name: 'refs', type: 'lookup', reference: 'account', multiple: true }).success).toBe(true); + expect(FieldSchema.safeParse({ name: 'flag', type: 'boolean' }).success).toBe(true); + }); +}); + for (const cell of DIALECT_CELLS) { if (!cell.available) { - declareUnprovisionedCell(cell, '[#17586] the multi-valued boolean read presentation'); + declareUnprovisionedCell(cell, '[#17586] the JSON-column read presentation'); continue; } declareReadSweep(cell); @@ -225,7 +175,7 @@ for (const cell of DIALECT_CELLS) { * it compiles. */ function declareReadSweep(cell: DialectCell): void { - describe(`[#17586] SqlDriver — reading a multi-valued boolean column (${cell.label})`, () => { + describe(`[#17586] SqlDriver — reading a JSON column (${cell.label})`, () => { let driver: SqlDriver; let knexInstance: Knex; let rows: Record[]; @@ -244,40 +194,24 @@ function declareReadSweep(cell: DialectCell): void { await driver?.disconnect?.(); }); - it('⭐ a stored `[false]` does NOT present as `true` — the inversion the card is filed for', () => { - const beta = rows.find((r) => r.id === '2')!; - // The opposite-of-stored assertion, spelled as its own expectation so a - // failure reads as the inversion and not as a shape mismatch. - expect(beta.toggles, 'toggles: stored [false] presented as `true`').not.toBe(true); - expect(beta.flags, 'flags: stored [false] presented as `true`').not.toBe(true); - // …and the same failure two members wide. - const gamma = rows.find((r) => r.id === '3')!; - expect(gamma.flags, 'flags: stored [false,false] presented as `true`').not.toBe(true); - }); - - it('the stored array survives the read, member for member', () => { - expect(rows.find((r) => r.id === '1')!.flags).toEqual([true, false]); - expect(rows.find((r) => r.id === '1')!.toggles).toEqual([true]); - expect(rows.find((r) => r.id === '2')!.flags).toEqual([false]); - expect(rows.find((r) => r.id === '2')!.toggles).toEqual([false]); - expect(rows.find((r) => r.id === '3')!.flags).toEqual([false, false]); - expect(rows.find((r) => r.id === '3')!.toggles).toEqual([true, false]); - }); - - it('every member is a real JS boolean, not the stored encoding', () => { + it('⭐ a stored array does NOT collapse to a single scalar — the inversion the card is filed for', () => { for (const row of rows) { - for (const field of ['flags', 'toggles']) { + for (const field of ['picks', 'refs', 'tags_']) { + expect(row[field], `${field} on row ${row.id} collapsed to a scalar`).not.toBe(true); expect(Array.isArray(row[field]), `${field} on row ${row.id}`).toBe(true); - for (const member of row[field] as unknown[]) { - expect(typeof member, `${field} member on row ${row.id}`).toBe('boolean'); - } } } }); - it('POSITIVE CONTROLS — the multi-valued number and the tags row are unmoved', () => { - expect(rows.find((r) => r.id === '1')!.nums).toEqual([1, 2]); - expect(rows.find((r) => r.id === '2')!.nums).toEqual([3]); + it('the stored array survives the read, member for member', () => { + expect(rows.find((r) => r.id === '1')!.picks).toEqual(['a', 'b']); + expect(rows.find((r) => r.id === '2')!.picks).toEqual(['c']); + expect(rows.find((r) => r.id === '3')!.picks).toEqual(['a', 'c']); + expect(rows.find((r) => r.id === '1')!.refs).toEqual(['r1', 'r2']); + expect(rows.find((r) => r.id === '2')!.refs).toEqual(['r3']); + }); + + it('POSITIVE CONTROL — the tags row is unmoved', () => { expect(rows.find((r) => r.id === '1')!.tags_).toEqual(['red']); expect(rows.find((r) => r.id === '3')!.tags_).toEqual(['red', 'blue']); }); @@ -293,117 +227,59 @@ function declareReadSweep(cell: DialectCell): void { expect(rows.find((r) => r.id === '3')!.scalar_toggle).toBe(true); }); + /** + * ⭐ [#17469] The storage half of the ruling, read end to end: a `boolean` + * carrying `multiple: true` is a PLAIN BOOLEAN COLUMN, so it takes the + * ordinary scalar coercion and presents the boolean that was written. + * Registry and storage agree, which is the invariant the original + * registry-narrowing repair was reaching for. + */ + it('[#17469] a RETIRED `boolean` + `multiple: true` reads back as the scalar boolean it now is', () => { + for (const row of rows) { + expect(typeof row.retired_flags, `retired_flags on row ${row.id}`).toBe('boolean'); + } + expect(rows.find((r) => r.id === '1')!.retired_flags).toBe(true); + expect(rows.find((r) => r.id === '2')!.retired_flags).toBe(false); + expect(rows.find((r) => r.id === '3')!.retired_flags).toBe(false); + }); + if (distinctExecutes(cell)) { /** * Reader 2, executed. `distinct()` returns raw builder output presented - * through {@link SqlDriver.readPresentationKind}, so before the repair - * this door answered `true` for every row — the same inversion the row - * door gave, which is why the card notes the collapse "is not confined - * to the row-read door". + * through {@link SqlDriver.readPresentationKind}, so a registry that + * claimed a JSON column was boolean answered `true` for every row — the + * same inversion the row door gave, which is why the card notes the + * collapse "is not confined to the row-read door". */ - it('reader 2 — `distinct()` does not collapse the column to a single `true`', async () => { - const values = await driver.distinct(READ_OBJECT, 'toggles', undefined, BYPASS); - expect(values, 'distinct() over a multi-valued toggle').not.toEqual([true]); + it('reader 2 — `distinct()` does not collapse a JSON column to a single `true`', async () => { + const values = await driver.distinct(READ_OBJECT, 'picks', undefined, BYPASS); + expect(values, 'distinct() over a multi-valued select').not.toEqual([true]); expect(values.every((v) => v === true), 'every distinct value coerced to `true`').toBe(false); }); - } else { - /** - * The NAMED DIVERGENCE, pinned rather than skipped — the same posture - * #17343's suite takes for the filter-side half of this property. - * - * ⛔ Pinned on the CLASS, not on a bare throw. The assertion is not - * "the boolean cell fails here" (which would stay green if this change - * had broken it); it is "the boolean cell fails EXACTLY as the columns - * this change cannot reach do" — `nums` is a `multiple: true` NUMBER - * whose registry carve-out (`NUMERIC_SCALAR_TYPES.has(type) && - * !field.multiple`) kept it out of `booleanFields` before this change - * and after it, and `tags_` was never a candidate at all. If some future - * edit made the boolean cell fail for a reason of its own, its error - * would stop matching the control's and this row goes red. - * - * ⚠️ [#17639] UPDATED BY THE CARD THIS ROW NAMED. When this suite - * landed, the error was asserted on the raw SQLSTATE as the CALLER's - * `code`, because this door did not wrap it: `distinct()` leaked the - * backend's own object — `code` was the raw `42883` and `status` was - * `undefined` — the gap #11455 closed for `aggregate()` and left open - * here. The note said the SQLSTATE row would go red on purpose when the - * envelope landed and that whoever fixed it should come and update this - * pin; #17639 has landed it and this is that update. - * - * The caller now receives the ADR-0112 terminal — `DATABASE_ERROR` / - * 500 — and the SQLSTATE rides the non-enumerable `cause`. BOTH halves - * are asserted, so this row stays a reading about the BACKEND's refusal - * (still the json-equality one, `42883`, on the control and on each - * field) and not merely about the wrapper. ⛔ Still no claim that this - * door should ANSWER here: that question is #17590's, unchanged. - */ - it('[#17590-family] `distinct()` over a JSON column is refused here — and the untouched NUMBER/tags controls are refused the SAME way', async () => { - const errorFor = async (field: string): Promise => - driver.distinct(READ_OBJECT, field, undefined, BYPASS).then( - () => null as unknown as WireBearingError, - (e: unknown) => e as WireBearingError, - ); - - const control = await errorFor('nums'); - expect(control, 'the multiple:true NUMBER control must reach the backend').toBeInstanceOf(Error); - expect(control.code, 'the control refusal carries the ADR-0112 envelope [#17639]').toBe('DATABASE_ERROR'); - expect(control.status, 'the control refusal carries a wire status [#17639]').toBe(500); - expect(sqlstateOf(control), 'the json-equality SQLSTATE survives as `cause`').toBe('42883'); - - for (const field of ['toggles', 'flags', 'tags_']) { - const err = await errorFor(field); - expect(err, `${field} must reach the backend, not a presented answer`).toBeInstanceOf(Error); - expect(err.code, `${field} fails identically to the untouched NUMBER control`).toBe(control.code); - expect(sqlstateOf(err), `${field} carries the control's SQLSTATE underneath`).toBe(sqlstateOf(control)); - } - }); /** - * …and the refusal really is about the COLUMN CLASS rather than about - * this door: a SCALAR boolean is a real `boolean` column on this backend, - * has an equality operator, and answers normally in the same run. Without - * this row the block above would also pass on a backend where `distinct()` - * was simply broken for everything. + * …and the refusal/answer really is about the COLUMN CLASS rather than + * about this door: a SCALAR boolean answers normally in the same run. */ - it('the SCALAR boolean answers normally at the same door — the refusal is per storage shape', async () => { + it('the SCALAR boolean answers normally at the same door — the reading is per storage shape', async () => { const values = await driver.distinct(READ_OBJECT, 'scalar_flag', undefined, BYPASS); expect([...values].sort()).toEqual([false, true]); }); /** * Reader 1, executed — the #11635 Postgres aggregate cast, on the one - * dialect it exists for. - * - * ⭐ What this narrowing does to that reader was measured on live - * PostgreSQL 16.13 by reading the statements the server actually - * received, two legs, `sql-driver.ts` blob verified on disk each time: - * - * | leg | statement PostgreSQL received | its refusal | - * |:--|:--|:--| - * | guard present (`f7fe22f8`) | `select max("flags") as "m"` | `function max(json) does not exist` | - * | guard reverted (`a2b37dc6`) | `select max(cast("flags" as int)) as "m"` | `cannot cast type json to integer` | - * - * ⇒ the registry entry really was buying this reader a `cast(?? as int)` - * over a `json` column, exactly as the enumeration predicted, and the - * guard stops it being emitted. Both shapes are refused by the backend — - * a multi-valued aggregand has no answer here either way — so what moves - * is only WHICH refusal, not a correct answer becoming an error. - * - * ⛔ The assertion below is the SCALAR half, deliberately, because that - * is the half a regression could silently take away: #11635 exists so a - * declared boolean can be aggregated on Postgres at all, and narrowing - * the registry must not cost it. Pinning the multi-valued half would mean - * asserting one dialect error string against another — brittle, and it - * would go red the day #17590's family is ruled. + * dialect it exists for. The SCALAR half deliberately: #11635 exists so a + * declared boolean can be aggregated on Postgres at all, and no narrowing + * of this registry may cost it. */ - it('reader 1 — the #11635 cast still answers for a SCALAR boolean after the narrowing', async () => { - const rows = await driver.aggregate( + it('reader 1 — the #11635 cast still answers for a SCALAR boolean', async () => { + const aggregated = await driver.aggregate( READ_OBJECT, { aggregations: [{ function: 'max', field: 'scalar_flag', alias: 'm' }] } as never, BYPASS, ); // `min`/`max` are pinned as the 0/1 the cast computes (#11152). - expect(Number(rows[0].m), 'max over a scalar boolean must still compute').toBe(1); + expect(Number(aggregated[0].m), 'max over a scalar boolean must still compute').toBe(1); }); } }); @@ -446,59 +322,62 @@ describe('[#17586] the `booleanFields` registry and its four readers', () => { const external = (config: SqlDriverConfig) => new RegistryProbeDriver(config).declareExternal(); for (const [label, config] of DIALECTS) { - it(`${label}: the registerExternalObject fill keeps multi-valued columns OUT of \`booleanFields\``, () => { - const registry = external(config).booleanRegistry(); - expect(registry, 'multi-valued boolean/toggle must not be registered').not.toContain('flags'); - expect(registry, 'multi-valued boolean/toggle must not be registered').not.toContain('toggles'); - // …and the narrowing is a carve-out, not a removal of the class. - expect(registry).toContain('scalar_flag'); - expect(registry).toContain('scalar_toggle'); + it(`${label}: the two registries PARTITION the columns — no JSON column is in \`booleanFields\``, () => { + const d = external(config); + const booleans = d.booleanRegistry(); + const json = d.jsonRegistry(); + for (const field of json) { + expect(booleans, `${field} is a JSON column and must not be in booleanFields`).not.toContain(field); + } + // Non-vacuity in both directions: the run really saw JSON columns… + expect(json).toEqual(expect.arrayContaining(['picks', 'refs', 'tags_'])); + // …and the boolean class is registered, so this is a partition and not a + // registry that gave up on the class. + expect(booleans).toContain('scalar_flag'); + expect(booleans).toContain('scalar_toggle'); + }); + + it(`${label}: [#17469] a RETIRED \`boolean\` + \`multiple\` is a SCALAR column — registry follows storage`, () => { + const d = external(config); + expect(d.jsonRegistry(), 'retired_flags must not be a JSON column').not.toContain('retired_flags'); + expect(d.booleanRegistry(), 'retired_flags is an ordinary boolean column').toContain('retired_flags'); }); - it(`${label}: reader 1 — no Postgres aggregate CAST is bought for a multi-valued column`, () => { + it(`${label}: reader 1 — no Postgres aggregate CAST is bought for a JSON column`, () => { // The #11635 gate is `isPostgres && … && booleanFields[table].includes(fieldExpr)`. // Its registry input is the assertion: absent from the registry, the cast // cannot fire, and `cast(json as int)` is never emitted. - expect(external(config).booleanRegistry()).not.toContain('flags'); + expect(external(config).booleanRegistry()).not.toContain('picks'); }); - it(`${label}: reader 2 — \`readPresentationKind\` no longer claims a multi-valued column is boolean`, () => { + it(`${label}: reader 2 — \`readPresentationKind\` never claims a JSON column is boolean`, () => { const d = external(config); - expect(d.presentationKind('flags'), 'flags').not.toBe('boolean'); - expect(d.presentationKind('toggles'), 'toggles').not.toBe('boolean'); + for (const field of ['picks', 'refs', 'tags_']) { + expect(d.presentationKind(field), field).not.toBe('boolean'); + } // The scalar column keeps the kind on exactly the dialects that store it // as a number — the per-dialect posture #11782 pinned. const scalarKind = d.presentationKind('scalar_flag'); expect(scalarKind, 'scalar_flag').toBe(label === 'postgres' ? null : 'boolean'); }); - it(`${label}: reader 4 — \`isNonTextColumn\` is BEHAVIOUR-IDENTICAL across the narrowing`, () => { + it(`${label}: reader 4 — \`isNonTextColumn\` never gates a JSON column`, () => { const d = external(config); - // The reader's own carve-out (`&& !isJsonColumn`) already excluded these, - // so narrowing the registry cannot move its answer. Both halves pinned: - // the multi-valued columns stay outside the gate… - expect(d.nonTextColumn('flags'), 'flags').toBe(false); - expect(d.nonTextColumn('toggles'), 'toggles').toBe(false); - // …and the scalar ones stay inside it, which is what makes this a - // carve-out rather than a hole in #14079's declared-type gate. + // The reader's own carve-out (`&& !isJsonColumn`) excludes these… + for (const field of ['picks', 'refs', 'tags_']) { + expect(d.nonTextColumn(field), field).toBe(false); + } + // …and the scalar ones stay inside the gate, which is what makes this a + // carve-out rather than a hole in #14079's declared-type gate. [#17469] + // `retired_flags` is inside it too: it is a scalar boolean column now. expect(d.nonTextColumn('scalar_flag'), 'scalar_flag').toBe(true); expect(d.nonTextColumn('scalar_toggle'), 'scalar_toggle').toBe(true); - }); - - it(`${label}: the equivalence reader 4 rests on — for boolean/toggle, \`isJsonColumn\` IS \`multiple\``, () => { - const d = external(config); - const json = d.jsonRegistry(); - // If these two ever diverge, narrowing the registry would silently move - // `isNonTextColumn`'s answer; this is the pin that says they do not. - expect(json, 'a multi-valued boolean is a JSON column').toContain('flags'); - expect(json, 'a multi-valued toggle is a JSON column').toContain('toggles'); - expect(json, 'a scalar boolean is not').not.toContain('scalar_flag'); - expect(json, 'a scalar toggle is not').not.toContain('scalar_toggle'); + expect(d.nonTextColumn('retired_flags'), 'retired_flags').toBe(true); }); } /** - * The two registry fills, side by side. The omission this card repairs was + * The two registry fills, side by side. The omission the card repairs was * present in BOTH, and they are separate code with no shared helper to make * that impossible — so a repair reaching only one leaves the defect live on * the other, exactly as #17343's round found. @@ -510,9 +389,11 @@ describe('[#17586] the `booleanFields` registry and its four readers', () => { await managed.initObjects([{ name: READ_OBJECT, fields: READ_FIELDS } as never]); try { expect(managed.booleanRegistry()).toEqual(ext.booleanRegistry()); - // …and the agreed registry is the narrowed one, not an agreed omission. - expect(managed.booleanRegistry()).not.toContain('flags'); - expect(managed.booleanRegistry()).not.toContain('toggles'); + expect(managed.jsonRegistry()).toEqual(ext.jsonRegistry()); + // …and the agreed registries are the partitioned ones, not an agreed + // omission. + expect(managed.booleanRegistry()).not.toContain('picks'); + expect(managed.jsonRegistry()).toContain('picks'); } finally { await managed.getKnex().schema.dropTableIfExists(READ_OBJECT).catch(() => {}); await managed.disconnect?.(); diff --git a/packages/drivers/driver-sql/src/sql-driver-17590-json-column-membership.test.ts b/packages/drivers/driver-sql/src/sql-driver-17590-json-column-membership.test.ts index ed469698a1..6cc44139ef 100644 --- a/packages/drivers/driver-sql/src/sql-driver-17590-json-column-membership.test.ts +++ b/packages/drivers/driver-sql/src/sql-driver-17590-json-column-membership.test.ts @@ -32,9 +32,15 @@ * `nums: [10, 21]` must NOT answer `$contains: '1'`. * * The three declared classes are the ruling's own fixture — `tags`, - * `multiselect`, and a `multiple: true` `number` — one from + * `multiselect`, and a `multiple: true` `select` — one from * `STRUCTURED_JSON_TYPES`' array neighbours, one from `MULTI_OPTION_TYPES`, and - * one that is a JSON column only because `multiple: true` says so. The scalar + * one that is a JSON column only because it is MULTI-VALUED. ⚠️ [#17469] That + * third row was a `multiple: true` `number` until the 2026-09-13 ruling gave + * "multi-valued" one definition (`isMultiValueField`) and made the driver's + * storage derive from it: `number` + `multiple: true` is refused at the + * authoring entrance and is a plain numeric column here. `select` carries the + * row unchanged — it is multi-capable, it is NOT a JSON-class type, and its + * option values are numbers, so the members are still JSON NUMBERS. The scalar * `label` column beside them is the NEGATIVE control and the other half of the * contract sentence: on a scalar string column `$contains` STAYS the substring * test, so a change that made membership universal would redden this file @@ -86,7 +92,7 @@ const FIELDS: Record> = { label: { type: 'string' }, tags_: { type: 'tags' }, picks: { type: 'multiselect' }, - nums: { type: 'number', multiple: true }, + nums: { type: 'select', multiple: true }, }; /** @@ -157,12 +163,12 @@ function declareMembershipCell(cell: DialectCell): void { }, LIVE_CELL_TIMEOUT_MS); /** - * The `multiple: true` NUMBER — the column whose members are JSON NUMBERS + * The multi-valued NUMERIC-OPTION column — whose members are JSON NUMBERS * while the contract declares the comparand a STRING. A type-strict * construct would answer nothing here, which is why the comparand denotes * two candidates. */ - it('$contains over a multiple:true NUMBER answers by MEMBER, not by digit substring', async () => { + it('$contains over a multi-valued numeric-option column answers by MEMBER, not by digit substring', async () => { expect(await ids({ nums: { $contains: '1' } })).toEqual(['1']); expect(await ids({ nums: { $contains: '2' } })).toEqual(['1', '3']); expect(await ids({ nums: { $contains: '10' } })).toEqual(['2']); @@ -311,21 +317,26 @@ describe('[#17590] the per-dialect membership construct, compiled', () => { /** * The POPULATION, pinned as a whole rather than as the three declared classes - * above: `isJsonColumn` is `JSON_COLUMN_TYPES.has(type) || !!field.multiple` - * (#17469's reading of the predicate the driver uses TODAY), so a class added - * to that set — or a `multiple: true` of any declared class — arrives with - * the membership construct instead of quietly keeping the substring one. + * above: `isJsonColumn` is `JSON_COLUMN_TYPES.has(type) || isMultiValueField(field)` + * (#17469's ruling of 2026-09-13 — one definition of multi-valued, and storage + * derives from it), so a class added to that set — or a MULTI-CAPABLE type + * flagged `multiple: true` — arrives with the membership construct instead of + * quietly keeping the substring one. * ⛔ The population is NOT widened here; this asserts the one that exists. + * ⚠️ The three multi-valued rows were `string` / `boolean` / `datetime` before + * that ruling. Those declarations are refused at the authoring entrance now + * and are plain scalar columns here, so pinning them would pin a branch the + * driver no longer has. */ - it('the population is every JSON column — declared class or multiple:true alike', () => { + it('the population is every JSON column — declared class or multi-valued alike', () => { const d = new CompilerProbeDriver(DIALECTS[0]![1]).declare({ - many_str: { type: 'string', multiple: true }, - many_bool: { type: 'boolean', multiple: true }, - many_date: { type: 'datetime', multiple: true }, + many_sel: { type: 'select', multiple: true }, + many_look: { type: 'lookup', multiple: true }, + many_user: { type: 'user', multiple: true }, checks: { type: 'checkboxes' }, one_str: { type: 'string' }, }); - for (const field of ['many_str', 'many_bool', 'many_date', 'checks']) { + for (const field of ['many_sel', 'many_look', 'many_user', 'checks']) { expect(d.compileWhere({ [field]: { $contains: 'x' } } as FilterCondition), field) .toMatch(CONSTRUCT.sqlite!); } diff --git a/packages/drivers/driver-sql/src/sql-driver-json-column-operator-refusal.test.ts b/packages/drivers/driver-sql/src/sql-driver-json-column-operator-refusal.test.ts index a893467a1e..7c9fdf8144 100644 --- a/packages/drivers/driver-sql/src/sql-driver-json-column-operator-refusal.test.ts +++ b/packages/drivers/driver-sql/src/sql-driver-json-column-operator-refusal.test.ts @@ -489,7 +489,12 @@ describe('[#7398] the second lowering family — normalised columns', () => { name: 'ext_sprint', fields: { id: { type: 'text', name: 'id' }, - milestones: { type: 'datetime', name: 'milestones', multiple: true }, + // [#17469] `select`, not `datetime`: the column is a JSON column here + // because it is MULTI-VALUED by `isMultiValueField`, the one definition + // the driver's storage now derives from. A `datetime` + `multiple: true` + // is refused at the authoring entrance and is a plain temporal column + // here, which is a different gate entirely. + milestones: { type: 'select', name: 'milestones', multiple: true }, }, }); }); diff --git a/packages/drivers/driver-sql/src/sql-driver-target-field-provenance.test.ts b/packages/drivers/driver-sql/src/sql-driver-target-field-provenance.test.ts index 18297e6235..9df2061749 100644 --- a/packages/drivers/driver-sql/src/sql-driver-target-field-provenance.test.ts +++ b/packages/drivers/driver-sql/src/sql-driver-target-field-provenance.test.ts @@ -70,7 +70,12 @@ describe('[#8197] target-field refusals × filter-subtree provenance', () => { stage: { type: 'text', name: 'stage' }, // `multiple: true` ⇒ stored as a JSON TEXT column, which is what makes // the #7398 gate — the most reachable member of this family — fire. - [POLICY_JSON_COL]: { type: 'text', name: POLICY_JSON_COL, multiple: true }, + // [#17469] On `select`, not `text`: "multi-valued" has one definition + // (`isMultiValueField`) and the driver's storage decision derives from + // it, so only a MULTI-CAPABLE type reaches the JSON column by this + // route — `text` + `multiple: true` is refused at the authoring + // entrance now and is a plain varchar column here. + [POLICY_JSON_COL]: { type: 'select', name: POLICY_JSON_COL, multiple: true }, [POLICY_NUM_COL]: { type: 'number', name: POLICY_NUM_COL }, }, } as any, diff --git a/packages/drivers/driver-sql/src/sql-driver.ts b/packages/drivers/driver-sql/src/sql-driver.ts index 7e3cce7f70..95376eee95 100644 --- a/packages/drivers/driver-sql/src/sql-driver.ts +++ b/packages/drivers/driver-sql/src/sql-driver.ts @@ -1766,6 +1766,28 @@ function isDeclaredFieldType(field: { type?: unknown }): boolean { return typeof field.type === 'string' && field.type !== ''; } +/** + * [#17469] Is this column MULTI-VALUED — the one question, asked of the one + * predicate `@objectstack/spec` publishes. + * + * Maintainer ruling 2026-09-13 (decision batch #128 item 5, option 1′): there is + * ONE definition of "multi-valued", `isMultiValueField`, and storage follows it. + * Every site below used to read `field.multiple` raw, which answered `true` on + * types the spec predicate calls single-valued — so the DDL built a JSON array + * column, the read-side deserializer agreed with it, and a consumer shaping a + * query from the SPEC predicate composed `=` against that column and was + * answered 400. `FieldSchema` now refuses the flag on those types at the + * authoring entrance, and this is the storage half of the same ruling. + * + * Takes the resolved `type` rather than reading `field.type`, because the + * callers below have already applied their own resolution (`field.type || + * 'string'`, the driver-internal alias default) and two spellings of that + * default is how the drift this fixes started. + */ +function isMultiValuedColumn(type: string, field: { multiple?: unknown } | null | undefined): boolean { + return isMultiValueField({ type, multiple: field?.multiple === true }); +} + /** * [#16319] DDL-time defence: a field declaration with NO `type` gets no column * — it gets a refusal. @@ -2487,8 +2509,8 @@ const CROSS_FIELD_COMPARISON_OPERATORS: ReadonlySet = new Set([ function crossFieldComparisonClass( decl: Record, ): 'numeric' | 'text' | 'boolean' | 'date' | 'datetime' | 'time' | null { - if (decl.multiple) return null; const type = String((decl as { type?: unknown }).type || 'string'); + if (isMultiValuedColumn(type, decl)) return null; if (type === 'formula') return null; if (JSON_COLUMN_TYPES.has(type) || FILE_REFERENCE_TYPES.has(type)) return null; if (NUMERIC_SCALAR_TYPES.has(type)) return 'numeric'; @@ -10548,16 +10570,18 @@ export class SqlDriver implements IDataDriver { if (this.isJsonField(type, field)) jsonCols.push(name); // Unconditional, on BOTH arms — see {@link mediaFields}. The read-side // legacy-encoding repair runs on a deployment that has not moved too. - if (!field.multiple && FILE_REFERENCE_TYPES.has(type)) mediaCols.push(name); - // [#17586] SCALAR only — `&& !field.multiple` is the house spelling - // its three neighbours in this block already carry, and this line was - // the single omission. See {@link booleanFields}: every reader of this - // registry presents its entry as ONE JS boolean, which for a - // multi-valued (JSON) column collapses the parsed array to `true`. - if ((type === 'boolean' || type === 'toggle') && !field.multiple) booleanCols.push(name); - if (NUMERIC_SCALAR_TYPES.has(type) && !field.multiple) numericCols.push(name); + if (!isMultiValuedColumn(type, field) && FILE_REFERENCE_TYPES.has(type)) mediaCols.push(name); + // [#17586] SCALAR only — the house spelling its three neighbours in + // this block already carry, and this line was the single omission. See + // {@link booleanFields}: every reader of this registry presents its + // entry as ONE JS boolean, which for a multi-valued (JSON) column + // collapses the parsed array to `true`. [#17469] The exclusion asks + // {@link isMultiValuedColumn}, so "excluded from the scalar registries" + // and "stored as a JSON column" stay the SAME population. + if ((type === 'boolean' || type === 'toggle') && !isMultiValuedColumn(type, field)) booleanCols.push(name); + if (NUMERIC_SCALAR_TYPES.has(type) && !isMultiValuedColumn(type, field)) numericCols.push(name); // [#16318] The authorable half only — see {@link numericValueFields}. - if (NUMERIC_VALUE_TYPES.has(type) && !field.multiple) numericValueCols.push(name); + if (NUMERIC_VALUE_TYPES.has(type) && !isMultiValuedColumn(type, field)) numericValueCols.push(name); if (type === 'date') dateCols.push(name); if (type === 'datetime') datetimeCols.push(name); if (type === 'time') timeCols.push(name); @@ -10633,7 +10657,7 @@ export class SqlDriver implements IDataDriver { } // Unconditional, on BOTH arms — see {@link mediaFields}. The read-side // legacy-encoding repair runs on a deployment that has not moved too. - if (!field.multiple && FILE_REFERENCE_TYPES.has(type)) { + if (!isMultiValuedColumn(type, field) && FILE_REFERENCE_TYPES.has(type)) { mediaCols.push(name); } // `toggle` shares boolean storage/affinity, so it needs the same @@ -10644,17 +10668,17 @@ export class SqlDriver implements IDataDriver { // coercion this registry exists for presents ONE JS boolean — which // collapses the parsed array to `true` whatever it holds. See // {@link booleanFields}. - if ((type === 'boolean' || type === 'toggle') && !field.multiple) { + if ((type === 'boolean' || type === 'toggle') && !isMultiValuedColumn(type, field)) { booleanCols.push(name); } // Numeric scalars are coerced back to JS numbers on read so legacy // TEXT-affinity columns (created before they were mapped to a numeric // column) still return numbers, not strings — see NUMERIC_SCALAR_TYPES. - if (NUMERIC_SCALAR_TYPES.has(type) && !field.multiple) { + if (NUMERIC_SCALAR_TYPES.has(type) && !isMultiValuedColumn(type, field)) { numericCols.push(name); } // [#16318] The authorable half only — see {@link numericValueFields}. - if (NUMERIC_VALUE_TYPES.has(type) && !field.multiple) { + if (NUMERIC_VALUE_TYPES.has(type) && !isMultiValuedColumn(type, field)) { numericValueCols.push(name); } if (type === 'date') { @@ -11264,7 +11288,7 @@ export class SqlDriver implements IDataDriver { if (!this.isMysql) return []; const candidates = new Set(AUDIT_TIMESTAMP_COLUMNS); for (const [name, field] of Object.entries(fields)) { - if ((field?.type ?? 'string') === 'datetime' && !field?.multiple) candidates.add(name); + if ((field?.type ?? 'string') === 'datetime' && !isMultiValuedColumn('datetime', field)) candidates.add(name); } if (candidates.size === 0) return []; @@ -11469,7 +11493,7 @@ export class SqlDriver implements IDataDriver { if (!this.isMysql) return []; const candidates = new Set(); for (const [name, field] of Object.entries(fields)) { - if ((field?.type ?? 'string') === 'time' && !field?.multiple) candidates.add(name); + if ((field?.type ?? 'string') === 'time' && !isMultiValuedColumn('time', field)) candidates.add(name); } if (candidates.size === 0) return []; @@ -16970,10 +16994,12 @@ export class SqlDriver implements IDataDriver { * to the spec enters that pin automatically. */ protected varcharColumnChars(field: any, keyed?: { unique: boolean }): number | null { - // `multiple` is decided before the type switch in `createColumn` — a JSON - // column, whatever the element type would have been. - if (field?.multiple) return null; + // Multi-value is decided before the type switch in `createColumn` — a JSON + // column, whatever the element type would have been. [#17469] The question + // is {@link isMultiValuedColumn}'s, exactly as it is there, so this mirror + // cannot answer a different one. const type = field?.type || 'string'; + if (isMultiValuedColumn(String(type), field)) return null; switch (type) { case 'string': case 'email': @@ -17334,17 +17360,24 @@ export class SqlDriver implements IDataDriver { // correct predicates. if (field.reference_to !== undefined) refuseRejectedReferenceAlias(name); - if (field.multiple) { + // [#17469] Was `if (field.multiple)`. The DDL writer and {@link isJsonField} + // are one source by the {@link JSON_COLUMN_TYPES} header's own contract + // ("so the two can't drift"), so the writer asks the same predicate the + // reader now asks. A field whose `multiple` the spec does not recognise on + // its type no longer gets a JSON column here — and `FieldSchema` refuses + // that declaration at the authoring entrance in the same ruling. + if (isMultiValuedColumn(String(field.type ?? ''), field)) { this.jsonColumn(table, name); return; } // [#16319] ⛔ Was `const type = field.type || 'string'`. See // {@link refuseUndeclaredFieldType} for what that default cost and why the - // answer here is a refusal rather than a different guess. Asked AFTER - // `multiple`, exactly where the default stood, so a flagged field is still a - // JSON column whatever its element type would have been — the rule the two - // generators and `fieldHasColumn` state as well. + // answer here is a refusal rather than a different guess. Asked after the + // multi-value short-circuit, exactly where the default stood — and a field + // with NO type is now refused rather than silently made a JSON column, + // which is this ruling's own direction: a type the spec never saw cannot + // carry a `multiple` the spec recognises. if (!isDeclaredFieldType(field)) refuseUndeclaredFieldType(name); const type: string = field.type; let col: any; @@ -18046,8 +18079,8 @@ export class SqlDriver implements IDataDriver { * and keep their column unchanged. */ protected isJsonField(type: string, field: any): boolean { - if (!field.multiple && FILE_REFERENCE_TYPES.has(type)) return this.mediaColumnIsJson(); - return JSON_COLUMN_TYPES.has(type) || isMultiValueField({ type, multiple: field.multiple }); + if (!isMultiValuedColumn(type, field) && FILE_REFERENCE_TYPES.has(type)) return this.mediaColumnIsJson(); + return JSON_COLUMN_TYPES.has(type) || isMultiValuedColumn(type, field); } // ── SQLite serialisation ──────────────────────────────────────────────────── diff --git a/packages/spec/src/data/value-roundtrip-conformance.ts b/packages/spec/src/data/value-roundtrip-conformance.ts index 2b55548e5d..36f4649a06 100644 --- a/packages/spec/src/data/value-roundtrip-conformance.ts +++ b/packages/spec/src/data/value-roundtrip-conformance.ts @@ -112,16 +112,28 @@ export type ValueRoundTripColumn = 'v_json' | 'v_multi' | 'v_string' | 'v_number * single fixture is one `create()` per row on every driver, and it keeps the * collision pairs — which span classes — expressible in one read. * - * `v_multi` is `multiple: true` on an ordinary string field. That is the - * #11535 shape and it is not a synonym for `v_json`: on the SQL family - * `multiple` decides the column type **before** the type switch runs, so a - * multi-value string and a declared `json` reach the JSON storage path by two - * different routes and can diverge independently. + * `v_multi` is `multiple: true` on a `lookup`. That is the #11535 shape and it + * is not a synonym for `v_json`: on the SQL family multi-value decides the + * column type **before** the type switch runs, so a multi-value reference and a + * declared `json` reach the JSON storage path by two different routes and can + * diverge independently. `lookup` carries that route because it is NOT itself a + * JSON-class type — a single-value `lookup` is a plain string column. + * + * ⚠️ [#17469] It used to be `{ type: 'string', multiple: true }`, and that + * shape no longer exists. The maintainer ruling of 2026-09-13 gives + * "multi-valued" ONE definition — `isMultiValueField` — which the driver's + * storage decision now derives from, and `FieldSchema` refuses `multiple: true` + * on every type outside it. A driver alias such as `string` is not even an + * authorable `FieldType`, so the old spelling reached the JSON route by a door + * the protocol had closed. The route under test is unchanged; only the type + * that carries it is one the protocol recognises. ⛔ Do not restore the old + * spelling to "keep the fixture ordinary" — it would pin a branch the writer no + * longer has. */ export const VALUE_ROUNDTRIP_FIELDS = { label: { type: 'string' }, v_json: { type: 'json' }, - v_multi: { type: 'string', multiple: true }, + v_multi: { type: 'lookup', multiple: true }, v_string: { type: 'string' }, v_number: { type: 'number' }, v_boolean: { type: 'boolean' }, From 6514a1f2b592b8c3b8beaef0f119a3ec230fef69 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 14 Sep 2026 12:23:04 +0000 Subject: [PATCH 5/6] docs(spec): state the full multi-value narrowing in the changeset and the ADR-0087 entry Claude-Session: https://claude.ai/code/session_01KB5PFtxuy1x3dcR5gxudx6 Co-authored-by: Claude --- ...17469-multiple-non-capable-type-refused.md | 41 +++++++++-- ...43-multi-valued-boolean-membership.test.ts | 2 +- ...river-json-column-operator-refusal.test.ts | 73 +++++++++++-------- ...field-multiple-non-capable-type-refused.ts | 15 +++- packages/spec/src/migrations/registry.ts | 15 +++- 5 files changed, 104 insertions(+), 42 deletions(-) diff --git a/.changeset/17469-multiple-non-capable-type-refused.md b/.changeset/17469-multiple-non-capable-type-refused.md index 43bf6701cb..535ef3e4f2 100644 --- a/.changeset/17469-multiple-non-capable-type-refused.md +++ b/.changeset/17469-multiple-non-capable-type-refused.md @@ -59,12 +59,34 @@ field that was already multi-valued by that predicate keeps its declaration, its storage and its read path byte-identically. What moved is which declarations can be newly authored, plus the storage decision for the shapes that are now refused. -**Storage change (`@objectstack/driver-sql`)**: `isJsonField` becomes -`JSON_COLUMN_TYPES.has(type) || isMultiValueField(field)`. The file's own header -already called `JSON_COLUMN_TYPES` membership "owned by `@objectstack/spec`"; that -sentence is now true for the `multiple` half too. A column whose field is -multi-valued by the spec predicate is a JSON column exactly as before; the shapes -that change are the ones the schema now refuses at the entrance. +**Storage change (`@objectstack/driver-sql`)**: every site that asked +`field.multiple` the question "is this value multi-valued" now asks +`isMultiValueField` — **seventeen expressions across two files**, not one. The +file's own header already called `JSON_COLUMN_TYPES` membership "owned by +`@objectstack/spec`"; that sentence is now true for the `multiple` half too. + +- `sql-driver.ts` — the DDL writer (`createColumn`'s multi-value short-circuit), + the read-side deserializer (`isJsonField`, both limbs), the `varchar` width + mirror (`varcharColumnChars`), the cross-field comparison class + (`crossFieldComparisonClass`), the four scalar registries filled by BOTH + `registerObjectMetadata` and `registerExternalObject` (`mediaFields`, + `booleanFields`, `numericFields`, `numericValueFields`), and the two MySQL + temporal-widening candidate sets. +- `schema-drift.ts` — the differ's `fieldHasColumn`, its `declaresJsonColumn` + disjunct and its `declaresArray` test, which #15771 bound to the writer's + predicate and which a pin test holds equal to it. + +Only the last of those was named in the ruling; aligning one and leaving sixteen +would have re-opened #11535 in reverse — the DDL writing a JSON column that the +read-side deserializer no longer recognises. A column whose field is multi-valued +by the spec predicate behaves exactly as before; the shapes that change are the +ones the schema now refuses at the entrance. + +⛔ Three `field.multiple` reads are deliberately NOT aligned: the three that +interpolate `', multiple'` into an `uncompilableFieldReferenceError` message. +They echo what the author DECLARED back to them; they do not ask whether the +value is multi-valued (the verdict there comes from `crossFieldComparisonClass`, +which is aligned). ⚠️ **Two consequences worth reading before you upgrade.** @@ -80,3 +102,10 @@ that change are the ones the schema now refuses at the entrance. JSON column. Declare such a column as `object` or `array` (both are `JSON_COLUMN_TYPES` members and unchanged), or as the authorable type it really is. +3. `multiple: true` on `boolean` / `toggle` / `number` / `currency` / `percent` / + `date` / `datetime` / `time` **ceases to be a supported shape end to end**, as + a consequence of the entrance refusal above. Such a column is no longer a JSON + column, so it is no longer excluded from the scalar read-coercion registries + and the declared-type text-operator gate (`isNonTextColumn`) applies to it: a + `$contains` against one answers the declared no-match rather than a JSON + membership test. Stored data in that shape is the ADR-0087 entry's subject. diff --git a/packages/drivers/driver-sql/src/sql-driver-17343-multi-valued-boolean-membership.test.ts b/packages/drivers/driver-sql/src/sql-driver-17343-multi-valued-boolean-membership.test.ts index a2cfa87ab3..368ecf4a06 100644 --- a/packages/drivers/driver-sql/src/sql-driver-17343-multi-valued-boolean-membership.test.ts +++ b/packages/drivers/driver-sql/src/sql-driver-17343-multi-valued-boolean-membership.test.ts @@ -134,7 +134,7 @@ describe('[#17469] the entrance half — the declarations this file used to pin expect(FieldSchema.safeParse({ name: 'refs', type: 'lookup', reference: 'account', multiple: true }).success).toBe(true); expect(FieldSchema.safeParse({ name: 'picks', type: 'select', multiple: true, - options: [{ label: 'A', value: 'a' }, { label: 'B', value: 'b' }], + options: [{ label: 'Alpha', value: 'alpha' }, { label: 'Beta', value: 'beta' }], }).success).toBe(true); }); }); diff --git a/packages/drivers/driver-sql/src/sql-driver-json-column-operator-refusal.test.ts b/packages/drivers/driver-sql/src/sql-driver-json-column-operator-refusal.test.ts index 7c9fdf8144..346f499a73 100644 --- a/packages/drivers/driver-sql/src/sql-driver-json-column-operator-refusal.test.ts +++ b/packages/drivers/driver-sql/src/sql-driver-json-column-operator-refusal.test.ts @@ -489,12 +489,7 @@ describe('[#7398] the second lowering family — normalised columns', () => { name: 'ext_sprint', fields: { id: { type: 'text', name: 'id' }, - // [#17469] `select`, not `datetime`: the column is a JSON column here - // because it is MULTI-VALUED by `isMultiValueField`, the one definition - // the driver's storage now derives from. A `datetime` + `multiple: true` - // is refused at the authoring entrance and is a plain temporal column - // here, which is a different gate entirely. - milestones: { type: 'select', name: 'milestones', multiple: true }, + milestones: { type: 'datetime', name: 'milestones', multiple: true }, }, }); }); @@ -507,35 +502,51 @@ describe('[#7398] the second lowering family — normalised columns', () => { // If this ever returns `null`, the cell has silently become a copy of the // block above and stops controlling anything. expect((driver as any).filterColumnExpr('ext_sprint', 'milestones', 'milestones')).not.toBeNull(); - expect((driver as any).isJsonColumn('ext_sprint', 'milestones')).toBe(true); }); - for (const [op, comparand] of [ - ['$in', [WHEN]], - ['$nin', [WHEN]], - ['$eq', WHEN], - ['$ne', WHEN], - ['$gt', WHEN], - ['$between', [WHEN, WHEN]], - ] as ReadonlyArray) { - it(`refuses "${op}" on the normalised JSON column`, async () => { - const err = await refusalOf(() => + /** + * ⭐ [#17469] THE INTERSECTION IS NOW EMPTY, and this row is what says so. + * + * The maintainer ruling of 2026-09-13 (decision batch #128 item 5, option + * 1′) gives "multi-valued" one definition — `isMultiValueField` — and + * derives this driver's storage decision from it. `datetime` is not a + * multi-capable type, so `{ type: 'datetime', multiple: true }` is refused at + * the authoring entrance AND is no longer a JSON column here. + * + * The normalised lowering family is reached through exactly two doors — + * `needsLegacyDatetimeRepair` and `needsLegacyTimeRepair` + * ({@link SqlDriver.filterColumnExpr}) — both of which require a DECLARED + * temporal field, and no declared temporal field can be multi-valued any + * more. So the population "a JSON column served by the normalised family" is + * empty, and #7398's gate covers that family as DEFENCE only. + * + * ⛔ Do not "repair" this block by re-declaring `milestones` as a + * multi-capable type. That makes it a JSON column again, but it also leaves + * the normalised family — `filterColumnExpr` answers `null` for it — so the + * block becomes a silent copy of the one above, which is the exact failure + * its own header names. The honest reading is the one below: the family is + * still reached, the column is no longer JSON, and the gate does not fire. + */ + it('[#17469] the normalised column is NO LONGER a JSON column — the gate has no population here', async () => { + expect((driver as any).isJsonColumn('ext_sprint', 'milestones')).toBe(false); + // …and the consequence, executed rather than inferred: the operators the + // gate refuses on a JSON column are compiled normally here. + for (const [op, comparand] of [ + ['$in', [WHEN]], + ['$eq', WHEN], + ['$gt', WHEN], + ['$between', [WHEN, WHEN]], + ] as ReadonlyArray) { + await expect( driver.find('ext_sprint', { where: authored({ milestones: { [op]: comparand } }) as FilterCondition, }), - ); - expectJsonColumnRefusal(err, op, 'milestones'); - }); - } - - it('refuses bare equality on the normalised JSON column', async () => { - const err = await refusalOf(() => - driver.find('ext_sprint', { where: authored({ milestones: WHEN }) as FilterCondition })); - expectJsonColumnRefusal(err, '=', 'milestones'); - }); - - it('$contains still works there too', async () => { - const rows = await driver.find('ext_sprint', { where: { milestones: { $contains: WHEN } } as FilterCondition }); - expect(rows.map((r: any) => r.id)).toEqual(['s1']); + `${op} must no longer be refused on this column`, + ).resolves.toBeDefined(); + } + await expect( + driver.find('ext_sprint', { where: authored({ milestones: WHEN }) as FilterCondition }), + 'bare equality must no longer be refused on this column', + ).resolves.toBeDefined(); }); }); diff --git a/packages/spec/src/migrations/entries/semantic/18.field-multiple-non-capable-type-refused.ts b/packages/spec/src/migrations/entries/semantic/18.field-multiple-non-capable-type-refused.ts index 8b392ff669..a080d4e31e 100644 --- a/packages/spec/src/migrations/entries/semantic/18.field-multiple-non-capable-type-refused.ts +++ b/packages/spec/src/migrations/entries/semantic/18.field-multiple-non-capable-type-refused.ts @@ -29,7 +29,17 @@ export const entry: SemanticMigration = { + 'meant is a business judgment the chain cannot make. Hence a structured TODO rather than an ' + 'auto-rewrite (ADR-0087 D3 "never silence", ADR-0032 "no silent failure"). Population ' + 'measured at ruling time: 0 in-tree and 0 in HotCRM (shallow clone c716a2c) — every ' - + '`multiple: true` there is on `lookup` / `select`.', + + '`multiple: true` there is on `lookup` / `select`; re-measured on origin/main 689d606f ' + + 'by AST sweep, still 0. ' + + 'WIDER THAN THE JSON-COLUMN DECISION ALONE: every site in driver-sql that asked ' + + '`field.multiple` "is this value multi-valued" now asks `isMultiValueField` \u2014 the DDL ' + + 'writer, the read-side deserializer, the varchar-width mirror, the cross-field ' + + 'comparison class, the four scalar read-coercion registries on both of their fills, ' + + 'the two MySQL temporal-widening candidate sets, and the schema differ. So a stored ' + + 'field in the retired shape also LEAVES the JSON read path and ENTERS the scalar one: ' + + 'its column is no longer deserialized as JSON, the declared-type text-operator gate ' + + 'applies to it, and a `$contains` against it answers the declared no-match instead of ' + + 'a membership test.', acceptanceCriteria: 'Every field in the stack parses: `ObjectSchema.parse()` / `objectstack validate` report no ' + 'issue on the `multiple` path. For each field the refusal names — the message states the ' @@ -38,7 +48,8 @@ export const entry: SemanticMigration = { + 'storages differ: the old column holds a JSON array, the new one holds a scalar (dropping ' + '`multiple`) or a differently-shaped array (changing `type`). Prove the data half by ' + 'reading one migrated row back through the API and asserting the value shape the new ' - + 'declaration promises; `=` filters against the field answer rows instead of a 400. Fields ' + + 'declaration promises; `=` filters against the field answer rows instead of a 400, and a ' + + '`$contains` against it answers by member rather than the declared no-match. Fields ' + 'already multi-valued by `isMultiValueField` need no change and must read back ' + 'byte-identically.', }; diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index 5dd7679210..96480140d9 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -7998,7 +7998,17 @@ const step18: MigrationStep = { + 'meant is a business judgment the chain cannot make. Hence a structured TODO rather than an ' + 'auto-rewrite (ADR-0087 D3 "never silence", ADR-0032 "no silent failure"). Population ' + 'measured at ruling time: 0 in-tree and 0 in HotCRM (shallow clone c716a2c) — every ' - + '`multiple: true` there is on `lookup` / `select`.', + + '`multiple: true` there is on `lookup` / `select`; re-measured on origin/main 689d606f ' + + 'by AST sweep, still 0. ' + + 'WIDER THAN THE JSON-COLUMN DECISION ALONE: every site in driver-sql that asked ' + + '`field.multiple` "is this value multi-valued" now asks `isMultiValueField` \u2014 the DDL ' + + 'writer, the read-side deserializer, the varchar-width mirror, the cross-field ' + + 'comparison class, the four scalar read-coercion registries on both of their fills, ' + + 'the two MySQL temporal-widening candidate sets, and the schema differ. So a stored ' + + 'field in the retired shape also LEAVES the JSON read path and ENTERS the scalar one: ' + + 'its column is no longer deserialized as JSON, the declared-type text-operator gate ' + + 'applies to it, and a `$contains` against it answers the declared no-match instead of ' + + 'a membership test.', acceptanceCriteria: 'Every field in the stack parses: `ObjectSchema.parse()` / `objectstack validate` report no ' + 'issue on the `multiple` path. For each field the refusal names — the message states the ' @@ -8007,7 +8017,8 @@ const step18: MigrationStep = { + 'storages differ: the old column holds a JSON array, the new one holds a scalar (dropping ' + '`multiple`) or a differently-shaped array (changing `type`). Prove the data half by ' + 'reading one migrated row back through the API and asserting the value shape the new ' - + 'declaration promises; `=` filters against the field answer rows instead of a 400. Fields ' + + 'declaration promises; `=` filters against the field answer rows instead of a 400, and a ' + + '`$contains` against it answers by member rather than the declared no-match. Fields ' + 'already multi-valued by `isMultiValueField` need no change and must read back ' + 'byte-identically.', }, From e5acc93608a7dadb75c4acc659e5ef64f2f4e6d3 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 14 Sep 2026 12:55:21 +0000 Subject: [PATCH 6/6] fix(driver-sql,cli): re-target the live-PG and CLI pins the multi-value narrowing moves, restore the PG named-divergence pin, and correct five shipping docblocks Claude-Session: https://claude.ai/code/session_01KB5PFtxuy1x3dcR5gxudx6 Co-authored-by: Claude --- ...17469-multiple-non-capable-type-refused.md | 4 +- ...nerate-declared-column-default.pin.test.ts | 12 ++- ...generate-field-type-vocabulary.pin.test.ts | 7 +- .../generate-multiple-json-column.pin.test.ts | 74 +++++++++------ .../drivers/driver-sql/src/schema-drift.ts | 7 +- ...ulti-valued-boolean-read-inversion.test.ts | 90 ++++++++++++++----- ...iver-17639-distinct-fault-envelope.test.ts | 62 ++++++++++--- packages/drivers/driver-sql/src/sql-driver.ts | 62 ++++++++----- packages/spec/src/data/field.zod.ts | 8 +- 9 files changed, 229 insertions(+), 97 deletions(-) diff --git a/.changeset/17469-multiple-non-capable-type-refused.md b/.changeset/17469-multiple-non-capable-type-refused.md index 535ef3e4f2..f1c4d3dc81 100644 --- a/.changeset/17469-multiple-non-capable-type-refused.md +++ b/.changeset/17469-multiple-non-capable-type-refused.md @@ -61,7 +61,7 @@ be newly authored, plus the storage decision for the shapes that are now refused **Storage change (`@objectstack/driver-sql`)**: every site that asked `field.multiple` the question "is this value multi-valued" now asks -`isMultiValueField` — **seventeen expressions across two files**, not one. The +`isMultiValueField` — **eighteen expressions across two files**, not one. The file's own header already called `JSON_COLUMN_TYPES` membership "owned by `@objectstack/spec`"; that sentence is now true for the `multiple` half too. @@ -76,7 +76,7 @@ file's own header already called `JSON_COLUMN_TYPES` membership "owned by disjunct and its `declaresArray` test, which #15771 bound to the writer's predicate and which a pin test holds equal to it. -Only the last of those was named in the ruling; aligning one and leaving sixteen +Only one of those was named in the ruling; aligning it and leaving seventeen would have re-opened #11535 in reverse — the DDL writing a JSON column that the read-side deserializer no longer recognises. A column whose field is multi-valued by the spec predicate behaves exactly as before; the shapes that change are the diff --git a/packages/cli/src/commands/generate-declared-column-default.pin.test.ts b/packages/cli/src/commands/generate-declared-column-default.pin.test.ts index d969d6b93e..241fdfac32 100644 --- a/packages/cli/src/commands/generate-declared-column-default.pin.test.ts +++ b/packages/cli/src/commands/generate-declared-column-default.pin.test.ts @@ -192,8 +192,16 @@ const FIELDS: Record = { c_token_user: { type: 'lookup', referenceTo: 'sys_user', defaultValue: 'current_user' }, c_expression: { type: 'text', defaultValue: { dialect: 'cel', source: 'today()' } }, c_option_default: { type: 'select', options: [{ label: 'A', value: 'a', default: true }] }, - // ...and the flag that returns before `createColumn` reaches either question. - c_multiple: { type: 'text', multiple: true, defaultValue: 'x' }, + // ...and the MULTI-VALUE short-circuit that returns before `createColumn` + // reaches either question. + // + // [#17469] On `lookup`, not `text`. The maintainer ruling of 2026-09-13 gives + // "multi-valued" one definition (`isMultiValueField`) and derives the driver's + // storage from it, so `text` + `multiple: true` is refused at the authoring + // entrance and is an ORDINARY VARCHAR COLUMN here — it reaches the default + // question and carries `DEFAULT 'x'`, which is the opposite of what this row + // is for. `lookup` is multi-capable, so it still short-circuits. + c_multiple: { type: 'lookup', multiple: true, defaultValue: 'x' }, }; /** The columns whose DEFAULT the driver is expected to emit — the non-vacuity set. */ diff --git a/packages/cli/src/commands/generate-field-type-vocabulary.pin.test.ts b/packages/cli/src/commands/generate-field-type-vocabulary.pin.test.ts index 9da764822b..ef9f5a8bf2 100644 --- a/packages/cli/src/commands/generate-field-type-vocabulary.pin.test.ts +++ b/packages/cli/src/commands/generate-field-type-vocabulary.pin.test.ts @@ -443,7 +443,12 @@ describe('#14828 — the SQL answers are the platform’s, not this file’s inv // The second statement of the same rule, in the differ. const at = SCHEMA_DRIFT_SOURCE.indexOf('export function fieldHasColumn('); expect(at, 'fieldHasColumn moved or was renamed in driver-sql').toBeGreaterThan(0); - expect(SCHEMA_DRIFT_SOURCE.slice(at, at + 200)).toContain("!== 'formula'"); + // [#17469] 600, not 200: `fieldHasColumn`'s first line is a call to + // `isMultiValueField(...)` with its argument object spelled out now, three + // times the width of the `field?.multiple` read it replaced. A window sized + // to the old spelling stops reaching the `formula` arm, and this assertion + // would then fail for a reason that has nothing to do with `formula`. + expect(SCHEMA_DRIFT_SOURCE.slice(at, at + 600)).toContain("!== 'formula'"); }); it('neither migration generator emits a column for a virtual field', () => { diff --git a/packages/cli/src/commands/generate-multiple-json-column.pin.test.ts b/packages/cli/src/commands/generate-multiple-json-column.pin.test.ts index 060b5403a9..8ffce0b57e 100644 --- a/packages/cli/src/commands/generate-multiple-json-column.pin.test.ts +++ b/packages/cli/src/commands/generate-multiple-json-column.pin.test.ts @@ -32,28 +32,34 @@ * places: * * packages/drivers/driver-sql/src/sql-driver.ts `createColumn` - * `if (field.multiple) { this.jsonColumn(table, name); return; }` — stated - * above the `switch (type)`, so the element type never gets a vote. + * the multi-value short-circuit — stated above the `switch (type)`, so the + * element type never gets a vote. * packages/drivers/driver-sql/src/sql-driver.ts `isJsonField` - * `JSON_COLUMN_TYPES.has(type) || !!field.multiple` * packages/drivers/driver-sql/src/schema-drift.ts `fieldHasColumn` - * `if (field?.multiple) return true;` — under the comment "Mirrors - * `SqlDriver.createColumn` exactly … everything else — including `multiple` - * (a JSON column) — gets one." + * — under the comment "Mirrors `SqlDriver.createColumn` exactly … + * everything else — including a MULTI-VALUED field (a JSON column) — gets + * one." * - * The spec's `isMultiValueField` is a DIFFERENT question with a different - * answer: it is the ADR-0104 D1 VALUE contract ("is the persisted value an - * array"), and it gates on `MULTI_CAPABLE_TYPES` — - * `MULTI_OPTION_TYPES.has(type) || (MULTI_CAPABLE_TYPES.has(type) && multiple)`. - * A generator that asked it instead would answer VARCHAR for a `text` field - * flagged `multiple: true` while the driver gives that same field a JSON - * column — reintroducing this very drift one notch narrower. `FieldSchema` - * does not refuse the combination either (`multiple` is a plain - * `z.boolean().default(false)` on every field; only `radio` + `multiple` is - * refused, by name, in `field.zod.ts`'s superRefine), and the CLI generators - * sit DOWNSTREAM of validation and explicitly serve the unvalidated authoring - * door. So the column authority is the driver's flag-first rule, and this pin - * asserts against that. + * ⭐ [#17469] WHAT "MULTI-VALUE" MEANS IN THOSE THREE PLACES CHANGED, and this + * header is the record of it. Until the maintainer ruling of 2026-09-13 + * (decision batch #128 item 5, option 1′) they all read `field.multiple` RAW, + * and this file argued at length that the spec's `isMultiValueField` was "a + * DIFFERENT question" that a generator must not ask. The ruling made it the + * SAME question: `FieldSchema` refuses `multiple: true` on every type outside + * `MULTI_CAPABLE_TYPES` ∪ `MULTI_OPTION_TYPES`, and all three driver sites now + * derive from `isMultiValueField`. + * + * ⚠️ So the two halves this file exists to hold together have SPLIT on the + * retired shapes, and the split is real and is recorded rather than papered + * over: `packages/cli/src/commands/generate.ts` still reads `field.multiple` + * raw, so `os generate migration` emits JSONB for a `text` field flagged + * `multiple: true` while the driver now emits a varchar for it — #14829 in + * reverse, one notch narrower. It is BOUNDED, because that declaration is + * refused at the authoring entrance and can only reach the generators through + * the unvalidated door they explicitly serve. Aligning `generate.ts` is + * another lane's card and is deliberately NOT done here; the assertions below + * therefore still state what the CLI emits, and the two source-read pins state + * what the driver decides, with this paragraph between them. * * `MULTI_CAPABLE_TYPES` is still imported here rather than transcribed — it is * the roster this pin SWEEPS, so a type added to that spec class is measured on @@ -198,10 +204,17 @@ describe('#14829 — `multiple: true` is one answer across all three surfaces', }); } - it('the flag decides before the type — a type outside MULTI_CAPABLE_TYPES too', () => { + it('the flag decides before the type in the GENERATORS — a type outside MULTI_CAPABLE_TYPES too', () => { // Stated as its own assertion because it is the one place this pin departs - // from the spec's value predicate on purpose. `text` is not multi-capable - // under `isMultiValueField`, and the driver gives it a JSON column anyway. + // from the spec's value predicate. + // + // ⚠️ [#17469] It is now a DIVERGENCE, not an agreement, and the assertion + // is unchanged for that reason: it states what `generate.ts` emits, which + // this card does not touch. The driver no longer gives this field a JSON + // column — `text` + `multiple: true` is refused at the authoring entrance + // and is a varchar there. Bounded by that refusal; aligning `generate.ts` + // is another lane's card. ⛔ Do not read this row as "the platform stores + // it as JSON" any more. expect(MULTI_CAPABLE_TYPES.has('text')).toBe(false); expect(sqlColumn('multi_text')).toBe('JSONB'); expect(tsColumn('multi_text')).toBe("table.jsonb('multi_text')"); @@ -252,19 +265,28 @@ describe('#14829 — `multiple: true` is one answer across all three surfaces', const preSwitch = source.slice(start, switchAt); expect( preSwitch, - 'driver-sql no longer short-circuits on `field.multiple` before its per-type switch. ' + + 'driver-sql no longer short-circuits on MULTI-VALUE before its per-type switch. ' + 'That short-circuit is the authority this pin and the CLI migration generators mirror ' + '(#14829) — re-derive both sides before changing it.', - ).toMatch(/if \(field\.multiple\)/); + ).toMatch(/if \(isMultiValuedColumn\(/); expect(preSwitch).toMatch(/this\.jsonColumn\(/); }); - it('driver-sql `fieldHasColumn` still answers the flag before the type', () => { + it('driver-sql `fieldHasColumn` still answers multi-value before the type', () => { const source = fs.readFileSync(path.join(DRIVER_SQL_SRC, 'schema-drift.ts'), 'utf8'); expect(source.length).toBeGreaterThan(10_000); const start = source.indexOf('export function fieldHasColumn('); expect(start, 'fieldHasColumn moved or was renamed in driver-sql').toBeGreaterThan(0); - expect(source.slice(start, start + 300)).toMatch(/if \(field\?\.multiple\) return true;/); + // [#17469] The window is 600 rather than 300: the predicate is a call to + // `isMultiValueField(...)` with its argument object spelled out, which is + // three times the width of the `field?.multiple` read it replaced. A + // window sized to the old spelling silently stops reaching the `formula` + // arm below it, which is the other half of what this pin is for. + const body = source.slice(start, start + 600); + expect(body, 'fieldHasColumn no longer answers the multi-value question first') + .toMatch(/if \(isMultiValueField\(/); + expect(body, 'the `formula` arm is no longer inside the read window') + .toMatch(/!== 'formula'/); }); // ── The former SCOPE FENCE for #14828 — DISCHARGED, and kept as the seam ── diff --git a/packages/drivers/driver-sql/src/schema-drift.ts b/packages/drivers/driver-sql/src/schema-drift.ts index c4e7ea91ef..545bb61a24 100644 --- a/packages/drivers/driver-sql/src/schema-drift.ts +++ b/packages/drivers/driver-sql/src/schema-drift.ts @@ -658,9 +658,10 @@ export const UNBOUNDED_TEXT_FIELD_TYPES: ReadonlySet = new Set([ * * `createColumn`'s catch-all is `JSON_COLUMN_TYPES.has(type) ? jsonColumn(...) * : table.string(...)` and `isJsonField` — the read-side deserializer — is - * `JSON_COLUMN_TYPES.has(type) || !!field.multiple`. So the writer has always - * asked about the TYPE as well as `multiple`, while the base-type branch below - * asked only `field.multiple === true`. A single-value JSON-class field on a + * `JSON_COLUMN_TYPES.has(type) || isMultiValueField(field)` (#17469 — was + * `|| !!field.multiple`). So the writer has always asked about the TYPE as well + * as multi-value, while the base-type branch below asked only + * `field.multiple === true`. A single-value JSON-class field on a * `varchar`/`text` column was therefore written as JSON and did not exist to * the differ, permanently and silently — the additive sync never revisits a * column. Two halves disagreeing about which declarations get a json column is diff --git a/packages/drivers/driver-sql/src/sql-driver-17586-multi-valued-boolean-read-inversion.test.ts b/packages/drivers/driver-sql/src/sql-driver-17586-multi-valued-boolean-read-inversion.test.ts index e0d5d4482e..d465a254c7 100644 --- a/packages/drivers/driver-sql/src/sql-driver-17586-multi-valued-boolean-read-inversion.test.ts +++ b/packages/drivers/driver-sql/src/sql-driver-17586-multi-valued-boolean-read-inversion.test.ts @@ -256,32 +256,78 @@ function declareReadSweep(cell: DialectCell): void { expect(values, 'distinct() over a multi-valued select').not.toEqual([true]); expect(values.every((v) => v === true), 'every distinct value coerced to `true`').toBe(false); }); - + } else { /** - * …and the refusal/answer really is about the COLUMN CLASS rather than - * about this door: a SCALAR boolean answers normally in the same run. + * The NAMED DIVERGENCE, pinned rather than skipped — the same posture + * #17343's suite takes for the filter-side half of this property. + * + * PostgreSQL's `json` type defines no equality operator and + * `SELECT DISTINCT` needs one, so this door is REFUSED there for every + * JSON column. It is pinned as the ADR-0112 envelope #17639 brought to + * this door, and on the CLASS rather than on a bare throw: `picks` is a + * multi-valued `select` and `tags_` an inherently-multi option type, and + * both must fail the SAME way, so a future edit that broke one of them + * for a reason of its own stops matching the other and reddens this row. + * + * ⚠️ [#17469] This row used to be aimed at `toggles` / `flags` — + * `toggle` / `boolean` carrying `multiple: true`. That declaration is + * refused at the authoring entrance now and is a SCALAR column here, so + * it is no longer a member of the class this row is about; the row is + * re-aimed at the JSON columns that still exist. ⛔ It was not deleted: + * the divergence is still live and still this file's to state. */ - it('the SCALAR boolean answers normally at the same door — the reading is per storage shape', async () => { - const values = await driver.distinct(READ_OBJECT, 'scalar_flag', undefined, BYPASS); - expect([...values].sort()).toEqual([false, true]); - }); - - /** - * Reader 1, executed — the #11635 Postgres aggregate cast, on the one - * dialect it exists for. The SCALAR half deliberately: #11635 exists so a - * declared boolean can be aggregated on Postgres at all, and no narrowing - * of this registry may cost it. - */ - it('reader 1 — the #11635 cast still answers for a SCALAR boolean', async () => { - const aggregated = await driver.aggregate( - READ_OBJECT, - { aggregations: [{ function: 'max', field: 'scalar_flag', alias: 'm' }] } as never, - BYPASS, - ); - // `min`/`max` are pinned as the 0/1 the cast computes (#11152). - expect(Number(aggregated[0].m), 'max over a scalar boolean must still compute').toBe(1); + it('the NAMED DIVERGENCE — `distinct()` over a JSON column is refused on this backend, as the envelope', async () => { + for (const column of ['picks', 'tags_']) { + let err: (Error & { code?: string; status?: number }) | undefined; + try { + await driver.distinct(READ_OBJECT, column, undefined, BYPASS); + } catch (e) { + err = e as Error & { code?: string; status?: number }; + } + expect(err, `distinct() over ${column} must be refused on this backend`).toBeDefined(); + expect(err!.code, `code for ${column}`).toBe('DATABASE_ERROR'); + expect(err!.status, `status for ${column}`).toBe(500); + // [#17639] The raw SQLSTATE the caller used to receive is the CAUSE now. + expect((err as unknown as { cause?: { code?: string } }).cause?.code, `SQLSTATE for ${column}`) + .toBe('42883'); + } }); } + + /** + * …and the refusal/answer really is about the COLUMN CLASS rather than + * about this door: a SCALAR boolean answers normally in the same run. + * + * ⚠️ [#17469] Outside the `distinctExecutes` branch on purpose. It is the + * CONTROL for the branch above — on PostgreSQL it is what proves the + * refusal is a property of `json` storage and not of a broken door — so + * running it only on the cells that never refuse is the one placement that + * makes it vacuous. + */ + it('the SCALAR boolean answers normally at the same door — the reading is per storage shape', async () => { + const values = await driver.distinct(READ_OBJECT, 'scalar_flag', undefined, BYPASS); + expect([...values].sort()).toEqual([false, true]); + }); + + /** + * Reader 1, executed — the #11635 Postgres aggregate cast, on the one + * dialect it exists for. The SCALAR half deliberately: #11635 exists so a + * declared boolean can be aggregated on Postgres at all, and no narrowing + * of this registry may cost it. + * + * ⚠️ [#17469] Outside the `distinctExecutes` branch on purpose, for the + * same reason: the dialect this reader exists for is exactly the one that + * branch excludes, so gating it there would run it nowhere it matters. + */ + it('reader 1 — the #11635 cast still answers for a SCALAR boolean', async () => { + const aggregated = await driver.aggregate( + READ_OBJECT, + { aggregations: [{ function: 'max', field: 'scalar_flag', alias: 'm' }] } as never, + BYPASS, + ); + // `min`/`max` are pinned as the 0/1 the cast computes (#11152). + expect(Number(aggregated[0].m), 'max over a scalar boolean must still compute').toBe(1); + }); }); } diff --git a/packages/drivers/driver-sql/src/sql-driver-17639-distinct-fault-envelope.test.ts b/packages/drivers/driver-sql/src/sql-driver-17639-distinct-fault-envelope.test.ts index 9f72fdba92..61ada54547 100644 --- a/packages/drivers/driver-sql/src/sql-driver-17639-distinct-fault-envelope.test.ts +++ b/packages/drivers/driver-sql/src/sql-driver-17639-distinct-fault-envelope.test.ts @@ -5,7 +5,7 @@ * * ## The measurement this suite is built from * - * `driver-sql` stores every `multiple: true` column as `json`, and PostgreSQL's + * `driver-sql` stores every MULTI-VALUED column as `json`, and PostgreSQL's * `json` type defines no equality operator, so `SELECT DISTINCT` over one is * refused by the backend. Until this card, `SqlDriver.distinct` awaited the * builder BARE — no `try`/`catch`, no envelope — so the refusal left the driver @@ -18,9 +18,20 @@ * could not identify an equality operator for type json * ``` * - * Class-wide across every JSON column — `toggle`, `boolean` and `number` with - * `multiple: true`, and `tags` — with a scalar `boolean` column in the same - * table answering `[false, true]` as the lit control. A raw `42883` is on no + * Class-wide across every JSON column, with a scalar `boolean` column in the + * same table answering `[false, true]` as the lit control. + * + * ⚠️ [#17469] The original measurement named `toggle`, `boolean` and `number` + * carrying `multiple: true` as three of those columns. The maintainer ruling of + * 2026-09-13 gives "multi-valued" ONE definition (`isMultiValueField`) and + * derives this driver's storage from it, so those three declarations are + * ordinary SCALAR columns now and are not this card's population any more. The + * fixture below carries the same class — what its members share is the `json` + * STORAGE, never the declared type — re-spelled onto `select` / `lookup` / + * `user` with `multiple: true`, plus `tags`. The retired shape stays in the + * table as the ruling's own control: it ANSWERS, because it is a real + * `boolean` column. Nothing about the ENVELOPE this card is fenced to has + * moved. A raw `42883` is on no * list `@objectstack/rest` reads, so `status` was `undefined` and an ordinary * caller shape (list the distinct values of this column) was logged as an * UNHANDLED server fault rather than served as a declared `DATABASE_ERROR` 500. @@ -294,7 +305,7 @@ for (const cell of DIALECT_CELLS) { // carried and #11635 fired. // // ⛔ Postgres only, and not for convenience: the divergence IS the dialect. -// SQLite stores a `multiple: true` column as TEXT and MySQL's `json` compares, +// SQLite stores a multi-valued column as TEXT and MySQL's `json` compares, // so `SELECT DISTINCT` answers on both; PostgreSQL's `json` defines no equality // operator and refuses. Asserting the refusal on the other two cells would pin // a fiction. The envelope invariant itself is measured on every cell by the @@ -314,22 +325,37 @@ if (PG_CELL) { { name: JSON_TABLE, fields: { - toggles: { type: 'toggle', multiple: true }, - flags: { type: 'boolean', multiple: true }, - nums: { type: 'number', multiple: true }, + // [#17469] The three columns here were `toggle` / `boolean` / + // `number` carrying `multiple: true`, and THAT SHAPE NO LONGER + // EXISTS. The maintainer ruling of 2026-09-13 gives + // "multi-valued" one definition (`isMultiValueField`), refuses + // the flag at the authoring entrance on every type outside it, + // and derives this driver's storage from the same predicate — so + // those declarations are ORDINARY SCALAR COLUMNS now, and writing + // `[false]` into one is a `boolean` column rejecting an array. + // What the card measures is the `json` STORAGE, which this + // fixture's own comment below already says, so the three columns + // are re-spelled onto multi-capable types that still reach it. + picks: { type: 'select', multiple: true }, + refs: { type: 'lookup', multiple: true }, + people: { type: 'user', multiple: true }, tags_: { type: 'tags' }, scalar_flag: { type: 'boolean' }, + // [#17469] The retired shape, kept as the ruling's own control: + // a scalar `boolean` column, which ANSWERS `distinct` instead of + // refusing it. + retired_flags: { type: 'boolean', multiple: true }, }, }, ]); await driver.create( JSON_TABLE, - { id: 'j1', toggles: [true], flags: [false], nums: [1], tags_: ['a'], scalar_flag: true }, + { id: 'j1', picks: ['alpha'], refs: ['r1'], people: ['u1'], tags_: ['a'], scalar_flag: true, retired_flags: true }, { bypassTenantAudit: true }, ); await driver.create( JSON_TABLE, - { id: 'j2', toggles: [false], flags: [true], nums: [2], tags_: ['b'], scalar_flag: false }, + { id: 'j2', picks: ['beta'], refs: ['r2'], people: ['u2'], tags_: ['b'], scalar_flag: false, retired_flags: false }, { bypassTenantAudit: true }, ); }); @@ -341,7 +367,7 @@ if (PG_CELL) { // Every declared shape the card measured, class-wide rather than // per-field-type: what they share is the `json` STORAGE, not the type. - for (const column of ['toggles', 'flags', 'nums', 'tags_']) { + for (const column of ['picks', 'refs', 'people', 'tags_']) { it(`distinct over '${column}' leaves as the envelope, not as pg's DatabaseError`, async () => { const err = await caught(() => driver.distinct(JSON_TABLE, column)); expect(err.code, 'code').toBe('DATABASE_ERROR'); @@ -360,6 +386,20 @@ if (PG_CELL) { const values = await driver.distinct(JSON_TABLE, 'scalar_flag'); expect([...values].sort(), 'scalar_flag').toEqual([false, true]); }); + + /** + * ⭐ [#17469] The ruling's own control, on the one cell that can see it. + * A `boolean` carrying `multiple: true` is NOT multi-valued, so it is a + * real `boolean` column here — not a `json` one — and `SELECT DISTINCT` + * therefore ANSWERS over it rather than raising 42883. This row is what + * turns red if the storage half of the ruling is reverted, and it is the + * second half of the fixture control: the refusals above are a property + * of `json` storage, not of the suite. + */ + it('[#17469] CONTROL a RETIRED `multiple` boolean is a scalar column now — it ANSWERS', async () => { + const values = await driver.distinct(JSON_TABLE, 'retired_flags'); + expect([...values].sort(), 'retired_flags').toEqual([false, true]); + }); }); }); } diff --git a/packages/drivers/driver-sql/src/sql-driver.ts b/packages/drivers/driver-sql/src/sql-driver.ts index 95376eee95..3ca63ecc51 100644 --- a/packages/drivers/driver-sql/src/sql-driver.ts +++ b/packages/drivers/driver-sql/src/sql-driver.ts @@ -4770,9 +4770,13 @@ export class SqlDriver implements IDataDriver { * fileColumnsMoved}, a live boolean, at call time. One registry, one * question, and the answer can arrive after registration. * - * ⚠️ `multiple: true` media is deliberately NOT here. Its value is a LIST of + * ⚠️ MULTI-VALUED media is deliberately NOT here. Its value is a LIST of * ids, it stays a JSON column on every deployment, and it is already covered - * by `jsonFields` through `!!field.multiple`. + * by `jsonFields` through {@link isMultiValuedColumn}. [#17469] That is + * `file` / `image` — the two `MULTI_CAPABLE_TYPES` members of the family. + * `avatar` / `video` / `audio` cannot be multi-valued at all any more (the + * flag is refused on them at the authoring entrance), so they are SINGLE- + * value media columns whatever they declare, and they belong here. */ protected mediaFields: Record = {}; /** @@ -4798,15 +4802,25 @@ export class SqlDriver implements IDataDriver { * registry: its readers present the stored form (SQLite INTEGER 0/1, MySQL * `tinyint(1)`) as one JS boolean. * - * ⚠️ [#17586] SCALAR only. A `multiple: true` boolean/toggle is deliberately + * ⚠️ [#17586] SCALAR only. A MULTI-VALUED boolean/toggle is deliberately * NOT here — the same carve-out {@link mediaFields} states just above, and * the one `numericFields` / `numericValueFields` carry in both fills. Its - * value is a LIST of booleans in a JSON column ({@link isJsonField} reduces - * to `!!field.multiple` for these two types, neither being in - * `JSON_COLUMN_TYPES`), and "present this as ONE boolean" has no meaning + * value is a LIST of booleans in a JSON column, and "present this as ONE + * boolean" has no meaning * over an array: `Boolean(v)` is `true` for EVERY non-empty array, so a * stored `[false]` presented as `true` is the OPPOSITE of what is stored, - * silently. The fills spell the condition rather than each reader because + * silently. + * + * ⭐ [#17469] The condition is {@link isMultiValuedColumn} now, and it has no + * reachable input on THESE two types any more: `boolean` / `toggle` are + * outside `MULTI_CAPABLE_TYPES`, the flag is refused on them at the + * authoring entrance, and a column that declares it is an ordinary boolean + * column which BELONGS in this registry. The condition stays because the + * registry must keep following storage, ⛔ not because the old shape + * survives — a JSON column must never be in here, whichever declaration + * produces one. + * + * The fills spell the condition rather than each reader because * no reader needs the entry — measured, all four: * * 1. the [#11635] Postgres aggregate CAST — would emit `cast(?? as int)` @@ -14479,8 +14493,9 @@ export class SqlDriver implements IDataDriver { * Each limb spells the exclusion where its own registry leaves it unsaid: * * - **numeric** — at the REGISTRY. `numericFields` is filled - * `NUMERIC_SCALAR_TYPES.has(type) && !field.multiple`, so the condition - * never reaches this predicate. + * `NUMERIC_SCALAR_TYPES.has(type) && !isMultiValuedColumn(type, field)` + * (#17469 — was `!field.multiple`), so the condition never reaches this + * predicate. * - **temporal** [#15683] — HERE. `dateFields` / `datetimeFields` / * `timeFields` serve the read-presentation seam, which DOES apply to a * multi-valued column, so narrowing them would break a seam that is right. @@ -14488,22 +14503,23 @@ export class SqlDriver implements IDataDriver { * #14079 landed this predicate describing itself as "a declared numeric or * boolean SCALAR" and annotated the numeric registry as non-`multiple`, so * the omission was the gap between that stated scope and `booleanFields`' - * silence, never a ruling that a stored array of booleans is meaningless: - * `boolean` + `multiple: true` is authorable, gets a JSON column here, and - * its `$contains` answered correctly on every other declared class. + * silence. * - * ⚠️ [#17586] `booleanFields` has SINCE been narrowed at both fills, for a + * ⚠️ [#17586] `booleanFields` was SINCE narrowed at both fills, for a * defect of its own (the read coercion collapsed a parsed array to a - * single, inverted `true`). So this limb's carve-out is now REDUNDANT — - * and it is kept deliberately, on two grounds: the registry's narrowing is - * a read-coercion decision that must not silently become this gate's - * correctness condition, and the carve-out is what states the rule for the - * limb — a text operator is legal against a JSON column — where the - * registry states only which columns take a coercion. The equivalence that - * makes the two agree (for `boolean`/`toggle`, `isJsonColumn` IS - * `!!field.multiple`) is pinned by execution in - * `sql-driver-17586-multi-valued-boolean-read-inversion.test.ts`, so a - * divergence turns that file red rather than moving this answer. + * single, inverted `true`). + * + * ⭐ [#17469] And the shape both of those cards were about no longer + * exists: `boolean` + `multiple: true` is refused at the authoring + * entrance and is not a JSON column here, so a boolean/toggle column can + * never BE a JSON column and this limb's carve-out has no reachable input. + * It is kept deliberately, as defence and as the statement of the rule for + * the limb — a text operator is legal against a JSON column — where the + * registry states only which columns take a coercion. + * `sql-driver-17586-multi-valued-boolean-read-inversion.test.ts` pins the + * partition the two now form (no JSON column is in `booleanFields`) and + * both halves of the #17469 ruling, so a divergence turns that file red + * rather than moving this answer. */ protected isNonTextColumn(table: string | null | undefined, localField: string): boolean { if (!table) return false; diff --git a/packages/spec/src/data/field.zod.ts b/packages/spec/src/data/field.zod.ts index 315e9e4607..ab7366044d 100644 --- a/packages/spec/src/data/field.zod.ts +++ b/packages/spec/src/data/field.zod.ts @@ -27,13 +27,7 @@ import { discriminateDefaultValueShape, suggestDefaultValueToken, } from './default-value-shape'; -import { - AddressSchema, - FILE_REFERENCE_TYPES, - MULTI_CAPABLE_TYPES, - MULTI_OPTION_TYPES, - REFERENCE_VALUE_TYPES, -} from './field-value.zod'; +import { AddressSchema, FILE_REFERENCE_TYPES, MULTI_CAPABLE_TYPES, MULTI_OPTION_TYPES, REFERENCE_VALUE_TYPES } from './field-value.zod'; // #7918 — the ISO 4217 / CLDR fraction-digit contradiction check (maintainer // ruling 2026-08-12, Option A). One shared verdict for both anchors: the // field-level `precision` key and `CurrencyConfigSchema.precision`.