From ce827aa22eb02051cbcdfbd337441af5a8f414f2 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 23:29:51 +0000 Subject: [PATCH 1/5] fix(objectql): store an empty multi-value as null at the write door An optional column on the $empty table's multi_value row (multiselect, checkboxes, tags, or a multi-capable type with multiple: true) written [] or '' now stores null. The core rule gains an 'emptyList' reach in blankAsNullColumns, and the door's row walk hands an empty list to it. Required columns, whitespace and non-empty lists are untouched. Claude-Session: https://claude.ai/code/session_01JQwxbZfihWgHXyWG4Mrd8N Co-authored-by: Claude --- .../blank-single-values.integration.test.ts | 6 + .../core/src/utils/blank-storage-form.test.ts | 36 ++- packages/core/src/utils/blank-storage-form.ts | 35 ++- ...record-validator.multi-value-blank.test.ts | 109 ++++++++ ...ecord-validator.single-value-blank.test.ts | 7 +- .../src/validation/record-validator.ts | 34 ++- .../src/rest-data-multi-value-blank.test.ts | 247 ++++++++++++++++++ 7 files changed, 449 insertions(+), 25 deletions(-) create mode 100644 packages/objectql/src/validation/record-validator.multi-value-blank.test.ts create mode 100644 packages/rest/src/rest-data-multi-value-blank.test.ts diff --git a/packages/cli/src/commands/migrate/blank-single-values.integration.test.ts b/packages/cli/src/commands/migrate/blank-single-values.integration.test.ts index 6636d0ac55b..fd2c78f0eb1 100644 --- a/packages/cli/src/commands/migrate/blank-single-values.integration.test.ts +++ b/packages/cli/src/commands/migrate/blank-single-values.integration.test.ts @@ -21,6 +21,7 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { ObjectQL } from '@objectstack/objectql'; import { SqlDriver } from '@objectstack/driver-sql'; +import { blankAsNullColumns } from '@objectstack/core'; import { blankSingleValueColumns, cleanBlankSingleValues } from './blank-single-values.js'; const OPTS = [{ value: 'gold', label: 'Gold' }, { value: 'silver', label: 'Silver' }]; @@ -100,6 +101,11 @@ describe('os migrate blank-single-values: the column set', () => { ['audio', 'avatar', 'file', 'image', 'lookup', 'master_detail', 'radio', 'select', 'tree', 'user', 'video'], ); }); + + it('[#29343] a multi_value-row column is in the door\'s map on its own reach, and out of this walk', () => { + expect(blankAsNullColumns(OBJ as any).get('m_select')).toBe('emptyList'); + expect(blankSingleValueColumns(OBJ).map((c) => c.name)).not.toContain('m_select'); + }); }); describe('os migrate blank-single-values: the walk on sqlite (#29431)', () => { diff --git a/packages/core/src/utils/blank-storage-form.test.ts b/packages/core/src/utils/blank-storage-form.test.ts index acbfa8d6c1a..2459e4e426b 100644 --- a/packages/core/src/utils/blank-storage-form.test.ts +++ b/packages/core/src/utils/blank-storage-form.test.ts @@ -24,7 +24,7 @@ const schema = { indexes: [{ fields: ['idx_a'], unique: true }, { fields: ['code'] }], }; -describe('blankAsNullColumns: the three arms of the write door\'s blank-as-null rule', () => { +describe('blankAsNullColumns: the four arms of the write door\'s blank-as-null rule', () => { const cols = blankAsNullColumns(schema); it('[#20308] a typed column takes any blank, required and lifecycle included', () => { @@ -44,7 +44,28 @@ describe('blankAsNullColumns: the three arms of the write door\'s blank-as-null expect(cols.get('stage')).toBe('empty'); expect(cols.get('ref')).toBe('empty'); expect(cols.get('pic')).toBe('empty'); - for (const out of ['stage_req', 'tags', 'blob', 'num', 'created_by']) expect(cols.has(out), out).toBe(false); + for (const out of ['stage_req', 'tags', 'blob', 'num', 'created_by']) expect(cols.get(out), out).not.toBe('empty'); + }); + + it('[#29343] "emptyList" on every optional column on the multi_value row, unique or not', () => { + const list = blankAsNullColumns({ + fields: { + tags: { type: 'select', multiple: true, options: [{ value: 'a' }] }, + ms: { type: 'multiselect', options: [{ value: 'a' }] }, + ms_u: { type: 'multiselect', unique: true, options: [{ value: 'a' }] }, + cb: { type: 'checkboxes', options: [{ value: 'a' }] }, + tg: { type: 'tags' }, + refs: { type: 'lookup', multiple: true }, + pics: { type: 'image', multiple: true }, + ms_req: { type: 'multiselect', required: true, options: [{ value: 'a' }] }, + created_by: { type: 'user', multiple: true }, + ref: { type: 'lookup' }, + }, + }); + for (const f of ['tags', 'ms', 'ms_u', 'cb', 'tg', 'refs', 'pics']) expect(list.get(f), f).toBe('emptyList'); + expect(list.has('ms_req')).toBe(false); + expect(list.has('created_by')).toBe(false); + expect(list.get('ref')).toBe('empty'); }); it('lifecycle names are the five engine-owned columns', () => { @@ -66,6 +87,17 @@ describe('blankStorageForm', () => { expect(blankStorageForm('', undefined)).toBe(''); }); + it('"emptyList" nulls [] and exactly ""; whitespace and a non-empty list stay', () => { + expect(blankStorageForm([], 'emptyList')).toBeNull(); + expect(blankStorageForm('', 'emptyList')).toBeNull(); + const list = ['a']; + expect(blankStorageForm(list, 'emptyList')).toBe(list); + for (const v of [' ', 'a', 0, false, null, undefined]) expect(blankStorageForm(v, 'emptyList')).toBe(v); + // An empty list on any other reach is not a blank of that column. + const empty: unknown[] = []; + for (const r of ['blank', 'empty', undefined] as const) expect(blankStorageForm(empty, r)).toBe(empty); + }); + it('a non-blank value or a non-string comes back as it is', () => { const obj = { a: 1 }; for (const v of ['a', 0, false, null, undefined, obj]) { diff --git a/packages/core/src/utils/blank-storage-form.ts b/packages/core/src/utils/blank-storage-form.ts index 66153ec550d..2126ddfcd37 100644 --- a/packages/core/src/utils/blank-storage-form.ts +++ b/packages/core/src/utils/blank-storage-form.ts @@ -1,7 +1,7 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. /** - * THE blank-as-null rule — which columns of an object store a blank string as + * THE blank-as-null rule — which columns of an object store a blank as * `null`, and which blanks — read by the write door that stores it and by the * seed loader's replay, which must compare a seed value in the form the door * stores it. @@ -22,7 +22,7 @@ * same reason. The type classes and the `$empty` table it reads are the * spec's, never re-listed. * - * ## The rule — three arms + * ## The rule — four arms * * - [#20308] `'blank'`: a column whose type is non-string-typed * (`NON_TEXT_STORED_VALUE_TYPES`: numeric, boolean, `date`, `datetime`, @@ -36,12 +36,20 @@ * table's `null_only` row (`expandEmptyOperator`) whose value is ONE string * ({@link ONE_STRING_STORED_TYPES}), unique or not (maintainer ruling B on * #29346, record 6579813296). + * - [#29343] `'emptyList'`: the empty list `[]`, or exactly `''`, on an + * optional column on the `$empty` table's `multi_value` row (a list-valued + * column: `isMultiValueField`, read through `expandEmptyOperator`), unique + * or not — the same ruling's execution step 2, which has this row follow + * the table for `[]` the way the `null_only` arm follows it for `''`. A + * stored `[]` collided under every NULL-distinct unique constraint as the + * JSON text `'[]'`, and `''` (never a member: `multiValueStorageForm` leaves + * a blank unwrapped) as the JSON text `'""'`. * - * The two `'empty'` arms never reach a `required: true` column (there `''` is - * refused `required`, and must not first become a `null` a `defaultValue` - * would fill) nor a lifecycle column the engine owns - * ({@link ENGINE_LIFECYCLE_COLUMNS}), and never whitespace (a value on both - * rows of the `$empty` table they touch). + * The `'empty'` and `'emptyList'` arms never reach a `required: true` column + * (there the blank is refused `required`, and must not first become a `null` + * a `defaultValue` would fill) nor a lifecycle column the engine owns + * ({@link ENGINE_LIFECYCLE_COLUMNS}), and never whitespace (a value on every + * row of the `$empty` table they touch). A non-empty list is never touched. */ import { @@ -108,9 +116,10 @@ const ONE_STRING_STORED_TYPES: ReadonlySet = new Set([ /** * Which blanks a column stores as `null`: `'blank'` — any blank string - * (`isMissing`); `'empty'` — exactly `''`. + * (`isMissing`); `'empty'` — exactly `''`; `'emptyList'` — the empty list + * `[]` or exactly `''` (a list-valued column). */ -export type BlankAsNullReach = 'blank' | 'empty'; +export type BlankAsNullReach = 'blank' | 'empty' | 'emptyList'; /** The field shape the rule reads. */ export interface BlankAsNullFieldDef extends ValueShapeFieldDef { @@ -137,9 +146,14 @@ export function blankAsNullColumns( continue; } if (LIFECYCLE.has(name) || def.required === true) continue; + const { arm } = expandEmptyOperator(def); + if (arm === 'multi_value') { + out.set(name, 'emptyList'); + continue; + } const empty = UNIQUE_BLANK_TEXT_TYPES.has(def.type) ? isCovered(name) - : expandEmptyOperator(def).arm === 'null_only' && ONE_STRING_STORED_TYPES.has(def.type); + : arm === 'null_only' && ONE_STRING_STORED_TYPES.has(def.type); if (empty) out.set(name, 'empty'); } return out; @@ -152,6 +166,7 @@ export function blankAsNullColumns( * {@link blankAsNullColumns} — keeps every value. */ export function blankStorageForm(value: unknown, reach: BlankAsNullReach | undefined): unknown { + if (reach === 'emptyList') return value === '' || (Array.isArray(value) && value.length === 0) ? null : value; if (reach === undefined || typeof value !== 'string') return value; if (reach === 'empty') return value === '' ? null : value; return isMissing(value) ? null : value; diff --git a/packages/objectql/src/validation/record-validator.multi-value-blank.test.ts b/packages/objectql/src/validation/record-validator.multi-value-blank.test.ts new file mode 100644 index 00000000000..1ac7eb1a875 --- /dev/null +++ b/packages/objectql/src/validation/record-validator.multi-value-blank.test.ts @@ -0,0 +1,109 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #29343 — the write door's arm for a cleared list-valued column on the + * `$empty` table's `multi_value` row, at the validator module: + * `normalizeBlankTypedValues` stores the empty list `[]`, or exactly `''`, as + * `null` on every OPTIONAL column the table puts on that row (maintainer + * ruling B on #29346, record 6579813296, execution step 2). + * + * The population is DERIVED from the table: every `FieldType` whose bare + * expansion is `multi_value`, plus every type whose expansion becomes + * `multi_value` under `multiple: true`, so a type joining the row joins this + * arm without a list here. The engine and physical-column halves, through + * REST on SQLite, are `packages/rest/src/rest-data-multi-value-blank.test.ts`. + */ + +import { describe, it, expect } from 'vitest'; +import { FieldType, REFERENCE_VALUE_TYPES, expandEmptyOperator } from '@objectstack/spec/data'; +import { normalizeBlankTypedValues } from './record-validator.js'; + +type Def = Record; +/** One field definition per multi_value-row shape: bare, then `multiple: true`. */ +const shapes: Array<[string, Def]> = []; +for (const t of FieldType.options) { + if (expandEmptyOperator({ type: t }).arm === 'multi_value') shapes.push([`f_${t}`, { type: t }]); + else if (expandEmptyOperator({ type: t, multiple: true }).arm === 'multi_value') { + shapes.push([`f_${t}_multi`, { type: t, multiple: true }]); + } +} + +function schemaOf(extra: Def = {}) { + const fields: Record = {}; + for (const [name, def] of shapes) { + fields[name] = { + name, + ...def, + ...(REFERENCE_VALUE_TYPES.has(def.type as string) ? { reference: 'other' } : {}), + options: [{ value: 'a', label: 'A' }], + ...extra, + }; + } + return { fields } as any; +} +const rowOf = (value: () => unknown) => Object.fromEntries(shapes.map(([name]) => [name, value()])); + +describe('normalizeBlankTypedValues: optional list-valued columns on the multi_value row (#29343)', () => { + it('the population is the multi_value row: three bare types and six multiple: true types', () => { + // A control on the derivation itself: if it ever emptied, every case below + // would pass over nothing. + expect(shapes.map(([n]) => n).sort()).toEqual([ + 'f_checkboxes', 'f_file_multi', 'f_image_multi', 'f_lookup_multi', 'f_multiselect', + 'f_radio_multi', 'f_select_multi', 'f_tags', 'f_user_multi', + ]); + }); + + it('[] and "" on every optional member become null, unique or not', () => { + for (const extra of [{}, { unique: true }, { required: false }]) { + for (const blank of [() => [], () => '']) { + const out = normalizeBlankTypedValues(schemaOf(extra), rowOf(blank)) as Record; + for (const [n] of shapes) expect(out[n], `${n} ${JSON.stringify(blank())} ${JSON.stringify(extra)}`).toBeNull(); + } + } + }); + + it('a required column keeps [] and "" (refused `required` downstream), with or without a defaultValue', () => { + for (const extra of [{ required: true }, { required: true, defaultValue: ['a'] }]) { + for (const blank of [() => [], () => '']) { + const row = rowOf(blank); + expect(normalizeBlankTypedValues(schemaOf(extra), row), JSON.stringify(extra)).toBe(row); + } + } + }); + + it('a lifecycle column the engine owns is never rewritten', () => { + const schema = { fields: { created_by: { name: 'created_by', type: 'user', multiple: true } } } as any; + const row = { created_by: [] }; + expect(normalizeBlankTypedValues(schema, row)).toBe(row); + }); + + it('whitespace, a non-empty list, and every other value pass through untouched', () => { + for (const v of [' ', '\t', 'a', ['a'], [''], null, undefined, 0, false, {}]) { + const row = rowOf(() => v); + expect(normalizeBlankTypedValues(schemaOf(), row), JSON.stringify(v)).toBe(row); + } + }); + + it('an empty list on a column off the multi_value row is not this arm', () => { + const schema = { + fields: { + blob: { name: 'blob', type: 'json' }, + rep: { name: 'rep', type: 'repeater' }, + pick: { name: 'pick', type: 'select', options: [{ value: 'a', label: 'A' }] }, + note: { name: 'note', type: 'text', unique: true }, + }, + } as any; + const row = { blob: [], rep: [], pick: [], note: [] }; + expect(normalizeBlankTypedValues(schema, row)).toBe(row); + }); + + it('pure: an array is rewritten per row, the caller rows are never mutated', () => { + const schema = schemaOf(); + const rows = [{ f_tags: [], f_multiselect: ['a'] }, { f_tags: ['x'], f_multiselect: ['a'] }]; + const out = normalizeBlankTypedValues(schema, rows) as Array>; + expect(out).not.toBe(rows); + expect(out).toEqual([{ f_tags: null, f_multiselect: ['a'] }, { f_tags: ['x'], f_multiselect: ['a'] }]); + expect(out[1]).toBe(rows[1]); + expect(rows[0]!.f_tags).toEqual([]); + }); +}); diff --git a/packages/objectql/src/validation/record-validator.single-value-blank.test.ts b/packages/objectql/src/validation/record-validator.single-value-blank.test.ts index a42c9f445a2..6cd1e1adf99 100644 --- a/packages/objectql/src/validation/record-validator.single-value-blank.test.ts +++ b/packages/objectql/src/validation/record-validator.single-value-blank.test.ts @@ -29,6 +29,7 @@ import { MULTI_CAPABLE_TYPES, expandEmptyOperator, } from '@objectstack/spec/data'; +import { blankAsNullColumns } from '@objectstack/core'; import { normalizeBlankTypedValues } from './record-validator.js'; /** Every declarable type the `$empty` table puts on `null_only` that the typed arm does not own. */ @@ -92,11 +93,11 @@ describe('normalizeBlankTypedValues: optional single-value option / reference co } }); - it('a multiple: true column is the multi_value row, not this arm: "" passes through', () => { + it('a multiple: true column is the multi_value row, not this arm: #29343\'s list arm owns it', () => { const schema = schemaOf(multiCapable, { multiple: true }); for (const t of multiCapable) expect(expandEmptyOperator(schema.fields[`f_${t}`]).arm, t).toBe('multi_value'); - const row = rowOf(multiCapable, ''); - expect(normalizeBlankTypedValues(schema, row)).toBe(row); + const reach = blankAsNullColumns(schema); + for (const t of multiCapable) expect(reach.get(`f_${t}`), t).toBe('emptyList'); }); it('a lifecycle column the engine owns is never rewritten', () => { diff --git a/packages/objectql/src/validation/record-validator.ts b/packages/objectql/src/validation/record-validator.ts index 7b789eee19b..530b696e298 100644 --- a/packages/objectql/src/validation/record-validator.ts +++ b/packages/objectql/src/validation/record-validator.ts @@ -735,8 +735,8 @@ function isInReadonlyScope(name: string, def: FieldDef, scope: ReadonlyValueScop * [#20308] A BLANK string on a non-string-typed column is that column's typed * blank, `null` — the write door's one reading of it. [#29176] So is `''` on * an optional text-like column a unique constraint covers, and [#29277] `''` - * on an optional single-value option, reference or file column (each its own - * section below). + * on an optional single-value option, reference or file column, and [#29343] + * `[]` or `''` on an optional list-valued column (each its own section below). * * "Blank" is exactly what {@link isMissing} calls missing: `''` or a string of * whitespace. "Non-string-typed" is the spec's `NON_TEXT_STORED_VALUE_TYPES` — @@ -789,23 +789,36 @@ function isInReadonlyScope(name: string, def: FieldDef, scope: ReadonlyValueScop * The same edges as the text arm: never whitespace (a value), never a * `required` column, and a blank on insert takes a declared `defaultValue`. * + * ## [#29343] List-valued columns on the `$empty` table's `multi_value` row + * + * The empty list `[]`, or exactly `''`, on an optional column on the + * `multi_value` row (`multiselect`, `checkboxes`, `tags`, a multi-capable type + * with `multiple: true`) is `null` from the door on, unique or not — the same + * ruling's execution step 2. An empty list holds no member, and `''` is never + * one (`normalizeMultiValueFields` leaves a blank unwrapped); stored as the + * JSON text `'[]'` / `'""'`, the second record cleared on a unique column was + * refused `409`. The ruled table reads `null` and `[]` as empty on that row, + * and is unchanged. The same edges: never whitespace, never a `required` + * column, a non-empty list untouched, and a blank on insert takes a declared + * `defaultValue`. + * * ## What it does NOT touch * - * ⛔ Every other string-stored column — text no unique constraint covers, a - * `multiple: true` option, reference or file column (the table's - * `multi_value` row), and the `null_only` types that do not store one string + * ⛔ Every other string-stored column — text no unique constraint covers, and + * the `null_only` types that do not store one string * (object/array-stored and engine-owned; `@objectstack/core`'s * `blank-storage-form.ts` says why each is out). There `''` is a value, and the * spec's value round-trip fixture pins it on text (`str_empty`: an empty - * string must not become null). ⛔ A non-blank value, and every non-string value, of any - * type: `null`, `0`, `false`, `'abc'` pass through untouched, so every type - * check and refusal downstream sees what it saw. + * string must not become null). ⛔ A non-blank value, and every non-string value + * but a list-valued column's `[]`, of any type: `null`, `0`, `false`, `'abc'`, + * `['a']` pass through untouched, so every type check and refusal downstream + * sees what it saw. * * ## One rule, two readers * * Which columns, and which blanks, is `blankAsNullColumns` / * `blankStorageForm` in `@objectstack/core` (`blank-storage-form.ts`) — the - * one spelling of all three arms. This door applies it; the seed loader's + * one spelling of all four arms. This door applies it; the seed loader's * replay (`@objectstack/metadata-protocol`, the `blankAsNull` replay * comparand) compares a seed value in the form it stores, so a seed `''` the * door stores as `null` is not a change on every replay. @@ -857,7 +870,8 @@ function normalizeBlankTypedRow( if (!isPlainRecord(row)) return row; let out: Record | undefined; for (const [name, value] of Object.entries(row)) { - if (typeof value !== 'string' || !isMissing(value)) continue; + const blank = typeof value === 'string' ? isMissing(value) : Array.isArray(value) && value.length === 0; + if (!blank) continue; if (blankStorageForm(value, reachOf(name)) !== null) continue; (out ??= { ...row })[name] = null; } diff --git a/packages/rest/src/rest-data-multi-value-blank.test.ts b/packages/rest/src/rest-data-multi-value-blank.test.ts new file mode 100644 index 00000000000..41cb8433064 --- /dev/null +++ b/packages/rest/src/rest-data-multi-value-blank.test.ts @@ -0,0 +1,247 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #29343 — a cleared optional multi-value field, through the REST write doors, + * on a real engine (`ObjectQL` + sqlite `SqlDriver`) and the real `RestServer` + * routes (the harness `rest-data-single-value-blank.test.ts` boots). + * + * The defect, measured on `origin/main` 322cf906: a multi-value column + * (`multiselect`, `checkboxes`, `tags`, or a multi-capable type with + * `multiple: true`) written `[]` stored the JSON text `[]`. On a unique column + * the second record cleared was refused `409 UNIQUE_VIOLATION` (SQL `UNIQUE` + * reads `'[]'` as a value and `NULL` as distinct from every `NULL`); a + * multi-value field written `''` stored the JSON text `'""'` and collided the + * same way. + * Maintainer ruling B on #29346 (record 6579813296), execution step 2: this + * card follows the `$empty` table's `multi_value` row for `[]` the way the + * #29277 arm follows its `null_only` row for `''` — the write door stores an + * empty list (or exactly `''`, never a member) as `null` on every OPTIONAL + * column the table puts on that row. + * + * The PHYSICAL column is read with the driver's own query builder. Pinned + * beside it: the `$empty` table answers as before (a row that already holds + * `[]` is still empty, and so is `null`), `required` still refuses `[]`, a + * blank on insert takes a declared `defaultValue`, a non-empty list is + * untouched, and a real duplicate is still `409`. + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { ObjectQL } from '@objectstack/objectql'; +import { SqlDriver } from '@objectstack/driver-sql'; +import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; +import { RestServer } from './rest-server'; + +const OPTS = [{ value: 'gold', label: 'Gold' }, { value: 'silver', label: 'Silver' }]; +const TARGET = { + name: 'mvb_target', label: 'Target', systemFields: false, + fields: { id: { name: 'id', type: 'text' as const, primaryKey: true }, name: { name: 'name', type: 'text' as const } }, +}; +// One optional column per multi_value-row shape, and a unique twin of each +// shape the card names (multiselect, a multi-value lookup), plus a unique-index +// member. +const OBJ = { + name: 'mvb_obj', label: 'Multi value blank', systemFields: false, + fields: { + id: { name: 'id', type: 'text' as const, primaryKey: true }, + name: { name: 'name', type: 'text' as const }, + c_multiselect: { name: 'c_multiselect', type: 'multiselect' as const, options: OPTS }, + c_checkboxes: { name: 'c_checkboxes', type: 'checkboxes' as const, options: OPTS }, + c_tags: { name: 'c_tags', type: 'tags' as const }, + c_select_multi: { name: 'c_select_multi', type: 'select' as const, options: OPTS, multiple: true }, + c_lookup_multi: { name: 'c_lookup_multi', type: 'lookup' as const, reference: 'mvb_target', multiple: true }, + c_image_multi: { name: 'c_image_multi', type: 'image' as const, multiple: true }, + u_multiselect: { name: 'u_multiselect', type: 'multiselect' as const, options: OPTS, unique: true }, + u_lookup_multi: { name: 'u_lookup_multi', type: 'lookup' as const, reference: 'mvb_target', multiple: true, unique: true }, + u_tags: { name: 'u_tags', type: 'tags' as const, unique: true }, + grp: { name: 'grp', type: 'text' as const }, + i_tags: { name: 'i_tags', type: 'tags' as const }, + }, + indexes: [{ name: 'idx_mvb_obj_grp_tags', fields: ['grp', 'i_tags'], unique: 'global' as const }], +}; +const PER_TYPE = [ + 'c_multiselect', 'c_checkboxes', 'c_tags', 'c_select_multi', 'c_lookup_multi', 'c_image_multi', +] as const; +const REQ = { + name: 'mvb_req', label: 'Required', systemFields: false, + fields: { + id: { name: 'id', type: 'text' as const, primaryKey: true }, + r_multiselect: { name: 'r_multiselect', type: 'multiselect' as const, options: OPTS, required: true }, + r_tags: { name: 'r_tags', type: 'tags' as const, required: true, defaultValue: ['x'] }, + }, +}; +const DEF = { + name: 'mvb_def', label: 'Defaults', systemFields: false, + fields: { + id: { name: 'id', type: 'text' as const, primaryKey: true }, + d_multiselect: { name: 'd_multiselect', type: 'multiselect' as const, options: OPTS, defaultValue: ['silver'] }, + }, +}; + +const liveEngines: ObjectQL[] = []; +afterEach(async () => { + while (liveEngines.length) { + try { await liveEngines.pop()?.destroy(); } catch { /* noop */ } + } +}); + +function createMockServer() { + const noop = () => {}; + return { get: noop, post: noop, put: noop, delete: noop, patch: noop, use: noop, listen: async () => {}, close: async () => {} }; +} + +function makeRes() { + const res: any = { + write: () => true, end: () => {}, + header: () => res, + status: (code: number) => { res._status = code; return res; }, + json: (body: any) => { res._json = body; return res; }, + }; + return res; +} + +async function boot() { + const engine = new ObjectQL(); + liveEngines.push(engine); + const driver = new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }); + engine.registerDriver(driver, true); + await engine.init(); + for (const o of [TARGET, OBJ, REQ, DEF]) engine.registry.registerObject(o as any); + await engine.syncSchemas(); + + const protocol = new ObjectStackProtocolImplementation(engine as any); + const rest = new RestServer(createMockServer() as any, protocol as any, { + api: { requireAuth: false }, batch: { enableBatchEndpoint: true }, + } as any); + (rest as any).resolveExecCtx = async () => ({ userId: 'test-user' }); + rest.registerRoutes(); + const call = async (method: string, path: string, params: Record, body: unknown) => { + const route = rest.getRoutes().find((r: any) => r.method === method && r.path === path); + expect(route, `${method} ${path}`).toBeDefined(); + const res = makeRes(); + await route!.handler({ params, body, query: {}, headers: {} } as any, res); + return { status: res._status ?? 200, body: res._json }; + }; + const knex = (driver as any).knex; + /** The physical row, read past every engine read coercion. */ + const stored = async (object: string, id: string) => + (await knex(object).where({ id }).first()) as Record | undefined; + await engine.insert('mvb_target', { id: 't1', name: 'T1' }); + await engine.insert('mvb_target', { id: 't2', name: 'T2' }); + return { engine, call, stored, knex }; +} + +type Ctx = Awaited>; +const create = (ctx: Ctx, body: Record, object = 'mvb_obj') => + ctx.call('POST', '/api/v1/data/:object', { object }, body); +const patch = (ctx: Ctx, id: string, body: Record) => + ctx.call('PATCH', '/api/v1/data/:object/:id', { object: 'mvb_obj', id }, body); +const allEmpty = Object.fromEntries(PER_TYPE.map((f) => [f, []])); + +describe('REST write doors on SQLite: a cleared optional multi-value field stores null (#29343)', () => { + let ctx: Ctx; + beforeEach(async () => { ctx = await boot(); }); + + it('POST create with [] on every multi_value shape: 201, every column stores NULL', async () => { + const res = await create(ctx, { id: 'p1', name: 'r1', ...allEmpty }); + expect(res.status).toBe(201); + const row = await ctx.stored('mvb_obj', 'p1'); + for (const f of PER_TYPE) expect(row?.[f], f).toBeNull(); + }); + + it('PATCH clearing every multi_value shape: 200, every column stores NULL', async () => { + const set = { + c_multiselect: ['gold'], c_checkboxes: ['gold'], c_tags: ['a'], + c_select_multi: ['gold'], c_lookup_multi: ['t1'], c_image_multi: ['img_1'], + }; + expect((await create(ctx, { id: 'e1', name: 'r1', ...set })).status).toBe(201); + const res = await patch(ctx, 'e1', allEmpty); + expect(res.status).toBe(200); + const row = await ctx.stored('mvb_obj', 'e1'); + for (const f of PER_TYPE) expect(row?.[f], f).toBeNull(); + }); + + for (const f of ['u_multiselect', 'u_lookup_multi', 'u_tags'] as const) { + for (const blank of [[], ''] as const) { + it(`unique ${f}: two records created with ${JSON.stringify(blank)} both land (no 409)`, async () => { + const a = await create(ctx, { id: 'u1', name: 'r1', [f]: blank }); + const b = await create(ctx, { id: 'u2', name: 'r2', [f]: blank }); + expect([a.status, b.status]).toEqual([201, 201]); + for (const id of ['u1', 'u2']) expect((await ctx.stored('mvb_obj', id))?.[f], id).toBeNull(); + }); + } + } + + it('unique multiselect and multi-value lookup: clearing two records by PATCH both land', async () => { + expect((await create(ctx, { id: 'w1', name: 'r1', u_multiselect: ['gold'], u_lookup_multi: ['t1'] })).status).toBe(201); + expect((await create(ctx, { id: 'w2', name: 'r2', u_multiselect: ['silver'], u_lookup_multi: ['t2'] })).status).toBe(201); + const first = await patch(ctx, 'w1', { u_multiselect: [], u_lookup_multi: [] }); + const second = await patch(ctx, 'w2', { u_multiselect: [], u_lookup_multi: [] }); + expect([first.status, second.status]).toEqual([200, 200]); + for (const id of ['w1', 'w2']) { + const row = await ctx.stored('mvb_obj', id); + expect([row?.u_multiselect, row?.u_lookup_multi], id).toEqual([null, null]); + } + }); + + it('a member of a declared unique index: same group, cleared tags, both land', async () => { + const a = await create(ctx, { id: 'i1', name: 'r1', grp: 'g', i_tags: [] }); + const b = await create(ctx, { id: 'i2', name: 'r2', grp: 'g', i_tags: [] }); + expect([a.status, b.status]).toEqual([201, 201]); + expect((await ctx.stored('mvb_obj', 'i2'))?.i_tags).toBeNull(); + }); + + it('batch create and createMany: every row lands, every [] stores NULL', async () => { + const bc = await ctx.call('POST', '/api/v1/data/:object/batch', { object: 'mvb_obj' }, { + operation: 'create', + records: [{ data: { id: 'b1', name: 'r1', u_multiselect: [] } }, { data: { id: 'b2', name: 'r2', u_multiselect: [] } }], + }); + expect(bc.status).toBe(200); + expect(bc.body.results.map((r: any) => r.success)).toEqual([true, true]); + const cm = await ctx.call('POST', '/api/v1/data/:object/createMany', { object: 'mvb_obj' }, + [{ id: 'm1', name: 'r3', u_lookup_multi: [] }, { id: 'm2', name: 'r4', u_lookup_multi: [] }]); + expect(cm.status).toBe(201); + for (const id of ['b1', 'b2']) expect((await ctx.stored('mvb_obj', id))?.u_multiselect, id).toBeNull(); + for (const id of ['m1', 'm2']) expect((await ctx.stored('mvb_obj', id))?.u_lookup_multi, id).toBeNull(); + }); + + it('a non-empty list is untouched, and a real duplicate is still refused: 409 UNIQUE_VIOLATION', async () => { + expect((await create(ctx, { id: 'd1', name: 'r1', u_multiselect: ['gold'] })).status).toBe(201); + expect((await ctx.stored('mvb_obj', 'd1'))?.u_multiselect).not.toBeNull(); + const dup = await create(ctx, { id: 'd2', name: 'r2', u_multiselect: ['gold'] }); + expect(dup.status).toBe(409); + expect(dup.body).toMatchObject({ code: 'UNIQUE_VIOLATION' }); + expect(await ctx.stored('mvb_obj', 'd2')).toBeUndefined(); + }); + + it('a required multi-value field refuses []: 400 VALIDATION_FAILED / required, with or without a defaultValue', async () => { + const res = await create(ctx, { id: 'q1', r_multiselect: [], r_tags: [] }, 'mvb_req'); + expect(res.status).toBe(400); + expect(res.body).toMatchObject({ code: 'VALIDATION_FAILED' }); + expect(res.body.fields.map((x: any) => [x.field, x.code]).sort()).toEqual([['r_multiselect', 'required'], ['r_tags', 'required']]); + expect(await ctx.stored('mvb_req', 'q1')).toBeUndefined(); + }); + + it('[] on insert takes a declared defaultValue, exactly as null does', async () => { + expect((await create(ctx, { id: 'f1', d_multiselect: [] }, 'mvb_def')).status).toBe(201); + expect((await create(ctx, { id: 'f2', d_multiselect: null }, 'mvb_def')).status).toBe(201); + const a = await ctx.engine.findOne('mvb_def', { where: { id: 'f1' } }) as Record; + const b = await ctx.engine.findOne('mvb_def', { where: { id: 'f2' } }) as Record; + expect(a.d_multiselect).toEqual(['silver']); + expect(b.d_multiselect).toEqual(['silver']); + }); + + it('the $empty table answers as before: a stored [] and a NULL are both empty', async () => { + // Written past the engine, as the before-state stored it. + await ctx.knex('mvb_obj').insert({ id: 'legacy', name: 'old', c_multiselect: '[]', c_lookup_multi: '[]' }); + expect((await create(ctx, { id: 'cleared', name: 'new', c_multiselect: [], c_lookup_multi: [] })).status).toBe(201); + expect((await create(ctx, { id: 'held', name: 'v', c_multiselect: ['gold'], c_lookup_multi: ['t1'] })).status).toBe(201); + expect((await ctx.stored('mvb_obj', 'legacy'))?.c_multiselect).toBe('[]'); + + const ids = async (where: Record) => + ((await ctx.engine.find('mvb_obj', { where, fields: ['id'] })) as Array<{ id: string }>).map((r) => r.id).sort(); + for (const f of ['c_multiselect', 'c_lookup_multi']) { + expect(await ids({ [f]: { $empty: true } }), f).toEqual(['cleared', 'legacy']); + expect(await ids({ [f]: { $empty: false } }), f).toEqual(['held']); + } + }); +}); From f147106ef594cd97e5585c754ec72fe9c543ec08 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 23:43:05 +0000 Subject: [PATCH 2/5] chore(changeset): declare the multi-value blank arm Claude-Session: https://claude.ai/code/session_01JQwxbZfihWgHXyWG4Mrd8N Co-authored-by: Claude --- .changeset/29343-multi-value-blank-null.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 .changeset/29343-multi-value-blank-null.md diff --git a/.changeset/29343-multi-value-blank-null.md b/.changeset/29343-multi-value-blank-null.md new file mode 100644 index 00000000000..cb5c1ea7247 --- /dev/null +++ b/.changeset/29343-multi-value-blank-null.md @@ -0,0 +1,19 @@ +--- +'@objectstack/objectql': patch +'@objectstack/core': patch +--- + +fix(objectql): a cleared optional multi-value field stores `null`, so a second record cleared on a unique one is no longer refused `409 UNIQUE_VIOLATION` + +Clause-②: yes (widening) +Metadata-protocol: none + +A cleared `multiselect`, `checkboxes` or `tags` field, or a `select`, `radio`, `lookup`, `user`, `file` or `image` field with `multiple: true`, sent as `[]` was stored as the empty list (the JSON text `[]` on SQL), and sent as `''` was stored as the JSON text `""`. Neither holds a member, yet each was a value to the store: on a unique column, or a column in a unique index, the second record cleared was refused "A record with this value already exists". + +- **Now.** The engine's write door (`insert`, `insertMany`, `update` by id and by predicate, and the `validate` dry run; every REST, batch, upsert and import door reaches it) stores `[]`, or exactly `''`, as `null` on every optional column the `$empty` table puts on its `multi_value` row. Unique or not. A non-empty list is stored as sent. +- **Read shape.** Such a field cleared from now on reads back `null`, not `[]`. It still matches `is_empty` / `$empty: true` (the table reads `null` and `[]` as empty on that row), and now also matches `$null: true` and `field = null`. +- **Unchanged.** The `$empty` table itself. A `required` multi-value field given `[]` or `''` is refused `required` as before, also when it declares a `defaultValue`. Whitespace is a value. A real duplicate is still `409 UNIQUE_VIOLATION`. +- **Defaults.** An optional multi-value field that declares a `defaultValue` now takes it on insert when sent `[]` or `''`, exactly as when sent `null`. +- **Rows already stored.** Existing `[]` and `""` values are not migrated by this change. They keep their stored form, still answer `$empty: true`, and still collide with each other on a unique column until a data step cleans them. `os migrate blank-single-values` does not touch these columns. +- **Seed replay.** A seed that writes `[]` or `''` on such a column is compared in the form the door stores it, `null`. A row that already stores `[]` is rewritten to `null` once on the next replay, then no longer changes. +- **Export.** `@objectstack/core`'s `BlankAsNullReach` gains the member `'emptyList'`, which `blankAsNullColumns` returns for these columns and `blankStorageForm` reads. From f0afc329a7d7e347605e2cfffdde283c42641c22 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 05:40:58 +0000 Subject: [PATCH 3/5] fix(objectql): pins, prose and docs follow ruling A: an emptied optional multi-value field stores null The set_null cascade repair that removes the last member of an optional multi-value reference now reads back null through the write door, so the objectql, runtime and service-automation pins move from [] to null, and the engine, required-emptiness and record-validator prose and six hand-written docs pages stop saying "[], never null". The required half (an empty list fails required) is unchanged. The changeset names the ruling, the widening and the read change. Claude-Session: https://claude.ai/code/session_01JQwxbWuRcoCQaZSTv51YHt Co-authored-by: Claude --- .changeset/29343-multi-value-blank-null.md | 4 ++- content/docs/api/data-api.mdx | 9 ++++--- content/docs/data-modeling/field-types.mdx | 4 +-- content/docs/data-modeling/fields.mdx | 6 ++--- .../docs/data-modeling/validation-rules.mdx | 15 +++++------ content/docs/deployment/troubleshooting.mdx | 12 +++++---- content/docs/protocol/objectql/types.mdx | 14 ++++++----- packages/core/src/utils/required-emptiness.ts | 11 ++++---- ...ne-cascade-delete-multivalue-probe.test.ts | 25 +++++++++++-------- .../engine-cascade-delete-user-field.test.ts | 4 +-- packages/objectql/src/engine.ts | 19 ++++++++------ .../src/validation/record-validator.test.ts | 7 +++--- ...lue-lookup-real-driver.integration.test.ts | 17 +++++++------ ...predicate-walk-batched.integration.test.ts | 5 ++-- 14 files changed, 85 insertions(+), 67 deletions(-) diff --git a/.changeset/29343-multi-value-blank-null.md b/.changeset/29343-multi-value-blank-null.md index cb5c1ea7247..48560a1c1c9 100644 --- a/.changeset/29343-multi-value-blank-null.md +++ b/.changeset/29343-multi-value-blank-null.md @@ -8,10 +8,12 @@ fix(objectql): a cleared optional multi-value field stores `null`, so a second r Clause-②: yes (widening) Metadata-protocol: none +This executes ruling A on #29343 (record 6591577985), which amends the #9447 sentence "an emptied multi-value lookup reads back as `[]`, never `null`" for optional columns: a cleared optional multi-value field is stored as `null`, the same as one never set. The spec half (the `multiple` and `required` doc blocks of `FieldSchema`) landed separately; this is the runtime half. It is a widening (Clause-②): a write that used to be refused `409` now lands, and no write that used to land is refused. + A cleared `multiselect`, `checkboxes` or `tags` field, or a `select`, `radio`, `lookup`, `user`, `file` or `image` field with `multiple: true`, sent as `[]` was stored as the empty list (the JSON text `[]` on SQL), and sent as `''` was stored as the JSON text `""`. Neither holds a member, yet each was a value to the store: on a unique column, or a column in a unique index, the second record cleared was refused "A record with this value already exists". - **Now.** The engine's write door (`insert`, `insertMany`, `update` by id and by predicate, and the `validate` dry run; every REST, batch, upsert and import door reaches it) stores `[]`, or exactly `''`, as `null` on every optional column the `$empty` table puts on its `multi_value` row. Unique or not. A non-empty list is stored as sent. -- **Read shape.** Such a field cleared from now on reads back `null`, not `[]`. It still matches `is_empty` / `$empty: true` (the table reads `null` and `[]` as empty on that row), and now also matches `$null: true` and `field = null`. +- **Read change.** An optional multi-value field cleared from now on reads back `null`, not `[]`, whoever clears it: an API or form write of `[]` or `''`, and the `set_null` cascade repair that removes the last member of a multi-value reference (it used to leave `[]`). A reader that branched on `Array.isArray` alone reads `null` and `[]` alike as empty. It still matches `is_empty` / `$empty: true` (the table reads `null` and `[]` as empty on that row), and now also matches `$null: true` and `field = null`. - **Unchanged.** The `$empty` table itself. A `required` multi-value field given `[]` or `''` is refused `required` as before, also when it declares a `defaultValue`. Whitespace is a value. A real duplicate is still `409 UNIQUE_VIOLATION`. - **Defaults.** An optional multi-value field that declares a `defaultValue` now takes it on insert when sent `[]` or `''`, exactly as when sent `null`. - **Rows already stored.** Existing `[]` and `""` values are not migrated by this change. They keep their stored form, still answer `$empty: true`, and still collide with each other on a unique column until a data step cleans them. `os migrate blank-single-values` does not touch these columns. diff --git a/content/docs/api/data-api.mdx b/content/docs/api/data-api.mdx index abbf151a787..acd389622d5 100644 --- a/content/docs/api/data-api.mdx +++ b/content/docs/api/data-api.mdx @@ -275,10 +275,11 @@ ROW: the delete is refused only over rows whose set the member removal would empty, and `dependentCount` counts just those rows — a row that keeps another member is updated and does not block the delete. On a `multiple: true` reference where `set_null` does run, it -removes just the deleted id from the array and keeps the rest, and a reference set -emptied that way reads back as `[]` — never `null`, so a client that branches on -`null` for "no link" misses the emptied case (the `multiple` doc block in -`packages/spec/src/data/field.zod.ts`, rendered in the +removes just the deleted id from the array and keeps the rest. On an optional field +a reference set emptied that way reads back as `null`, the same as one never set; a +row stored before that rule can still read back `[]`, so a client treats `null` and +`[]` alike as "no link", as the `is_empty` filter (`$empty`) does (the `multiple` doc +block in `packages/spec/src/data/field.zod.ts`, rendered in the [Field reference](/docs/references/data/field)). A record that the same delete removes never blocks it. Before any refusal is diff --git a/content/docs/data-modeling/field-types.mdx b/content/docs/data-modeling/field-types.mdx index 57e5a40bec4..e007ec45c54 100644 --- a/content/docs/data-modeling/field-types.mdx +++ b/content/docs/data-modeling/field-types.mdx @@ -496,7 +496,7 @@ Reference to a record in another object (foreign key). |:---|:---|:---|:---| | `reference` | `string` | **required** | Target object name (snake_case). A stored-metadata table (`sys_metadata`, `sys_metadata_history`) is refused — see [below](#a-relationship-never-targets-a-stored-metadata-table) | | `referenceFilters` | `string[]` | — | **Removed** (#2377, ADR-0049) — no longer a recognized field property. `FieldSchema` is a strict object, so an unknown key is **rejected with guidance**, not silently stripped (ADR-0078): the error echoes the offending key and prescribes the replacement. Use structured `lookupFilters` + `dependsOn` instead; see [Relationships](/docs/data-modeling/relationships) | -| `deleteBehavior` | `'restrict' \| 'cascade' \| 'set_null'` | `'set_null'` | Behavior when referenced record is deleted. On a *required* lookup `set_null` is escalated to `restrict`, since a NOT NULL foreign key cannot be cleared — **whether the `set_null` was defaulted or written out explicitly**. On a `multiple: true` required lookup the escalation is judged per referencing row: only a row the member removal would leave EMPTY is refused. `cascade` and `restrict` are the values honored as written. Where `set_null` does run, a `multiple: true` lookup loses only the deleted **member** — the other members are kept, and a set emptied that way is stored as `[]`, never `null` | +| `deleteBehavior` | `'restrict' \| 'cascade' \| 'set_null'` | `'set_null'` | Behavior when referenced record is deleted. On a *required* lookup `set_null` is escalated to `restrict`, since a NOT NULL foreign key cannot be cleared — **whether the `set_null` was defaulted or written out explicitly**. On a `multiple: true` required lookup the escalation is judged per referencing row: only a row the member removal would leave EMPTY is refused. `cascade` and `restrict` are the values honored as written. Where `set_null` does run, a `multiple: true` lookup loses only the deleted **member** — the other members are kept, and on an optional field a set emptied that way is stored as `null`, the same as one never set | ```typescript { name: 'company', label: 'Company', type: 'lookup', reference: 'account' } @@ -1022,7 +1022,7 @@ These properties are available on **all** field types: | `type` | `FieldType` | **required** | One of the supported field types | | `required` | `boolean` | `false` | Whether the field is required | | `unique` | `boolean \| 'global' \| 'organization'` | `false` (`'organization'` on `autonumber`) | Unique constraint **and its scope** (ADR-0120). `'organization'` = one holder per organization; `true` = that same per-organization scope (positional synonym — prefer the explicit spelling in new code); `'global'` = one holder across the whole installation; `false` = no constraint. 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; write `unique: false` explicitly to opt out. `'tenant'`/`'org'` are rejected — the word is `'organization'`. Full rule in the `unique` doc block in [`FieldSchema`](/docs/references/data/field) | -| `multiple` | `boolean` | `false` | Allow array of values (multi-record lookup, multi-select, multi-file). An emptied multi-value lookup reads back as `[]`, never `null` — the `multiple` doc block in [`FieldSchema`](/docs/references/data/field) | +| `multiple` | `boolean` | `false` | Allow array of values (multi-record lookup, multi-select, multi-file). An emptied optional multi-value field is stored as `null`, the same as one never set (`[]` and `""` written to it store `null`); a row stored before that rule can still hold `[]`, and `is_empty` matches both — the `multiple` doc block in [`FieldSchema`](/docs/references/data/field) | | `searchable` | `boolean` | `false` | Search this field when the object declares no `searchableFields` — any type but `secret` / `password` / `vector`; a declared `searchableFields` is the whole set | | `sortable` | `boolean` | `true` | Allow sorting by this field | | `hidden` | `boolean` | `false` | Hide from default views | diff --git a/content/docs/data-modeling/fields.mdx b/content/docs/data-modeling/fields.mdx index b7720dc9847..f1193ac4da0 100644 --- a/content/docs/data-modeling/fields.mdx +++ b/content/docs/data-modeling/fields.mdx @@ -292,9 +292,9 @@ Clearing a relationship is not a dangling reference: `null`, `""` and `[]` all m "no link". Which of them `deleteBehavior: 'set_null'` writes when the referenced record goes away depends on the field: a single-value lookup is set to `null`, while a `multiple: true` lookup keeps its other members and loses only the deleted one — -and a set emptied that way is written as `[]`, never `null` (the `multiple` doc block -in `packages/spec/src/data/field.zod.ts`, rendered in the -[Field reference](/docs/references/data/field)). +and on an optional field a set emptied that way is stored as `null`, the same as one +never set (the `multiple` doc block in `packages/spec/src/data/field.zod.ts`, rendered +in the [Field reference](/docs/references/data/field)). `deleteBehavior` governs what happens to *this* record when the **referenced** diff --git a/content/docs/data-modeling/validation-rules.mdx b/content/docs/data-modeling/validation-rules.mdx index 089725adf13..359de83e14d 100644 --- a/content/docs/data-modeling/validation-rules.mdx +++ b/content/docs/data-modeling/validation-rules.mdx @@ -261,13 +261,14 @@ These properties apply to **all** field types and are validated by the base `Fie **Default constraints:** Validates that referenced record exists. Foreign key integrity enforced. -**Multi-value lookups (`multiple: true`).** The empty set is representable: an emptied -multi-value lookup is stored and read back as `[]`, never `null` — including when -`deleteBehavior: 'set_null'` removes the last remaining member. The declared contract -lives in the `multiple` and `required` doc blocks of -`packages/spec/src/data/field.zod.ts` (rendered in the -[Field reference](/docs/references/data/field)), and it makes `required` on a -multi-value lookup mean **non-empty array**. +**Multi-value lookups (`multiple: true`).** An emptied optional multi-value lookup is +stored and read back as `null`, the same as one never set — `[]` and `""` written to it +store `null`, including when `deleteBehavior: 'set_null'` removes the last remaining +member. A row stored before that rule can still hold `[]`; the `is_empty` filter +(`$empty`) reads `null` and `[]` alike as empty. The declared contract lives in the +`multiple` and `required` doc blocks of `packages/spec/src/data/field.zod.ts` +(rendered in the [Field reference](/docs/references/data/field)), and it makes +`required` on a multi-value lookup mean **non-empty array**. The `required`-means-non-empty half is **enforced by the record validator** (#9476, per diff --git a/content/docs/deployment/troubleshooting.mdx b/content/docs/deployment/troubleshooting.mdx index 97052737de7..4aa75968adb 100644 --- a/content/docs/deployment/troubleshooting.mdx +++ b/content/docs/deployment/troubleshooting.mdx @@ -250,11 +250,13 @@ client.data.find('project_task', { /* query */ }); On a `multiple: true` lookup, `set_null` clears only the **member** that was deleted — -the child's other references survive — and a set emptied that way is stored as `[]`, -never `null` (the `multiple` doc block in `packages/spec/src/data/field.zod.ts`, -rendered in the [Field reference](/docs/references/data/field)). A cleanup query or -client branch that looks for `null` to find cleared references will not match those -rows; test for an empty array instead. +the child's other references survive — and on an optional field a set emptied that +way is stored as `null`, the same as one never set (the `multiple` doc block in +`packages/spec/src/data/field.zod.ts`, rendered in the +[Field reference](/docs/references/data/field)). A row stored before that rule can +still hold `[]`, so a cleanup query or client branch that finds cleared references +uses the `is_empty` filter (`$empty: true`), which matches both, rather than testing +for `null` or for an empty array alone. --- diff --git a/content/docs/protocol/objectql/types.mdx b/content/docs/protocol/objectql/types.mdx index e2b7e8bfd44..18edd138654 100644 --- a/content/docs/protocol/objectql/types.mdx +++ b/content/docs/protocol/objectql/types.mdx @@ -726,12 +726,14 @@ const opportunities = await engine.find('opportunity', { - `restrict`: Prevent deletion if references exist - `cascade`: Delete this record when referenced record is deleted -> **Residual shape of a multi-value reference.** A `multiple: true` lookup emptied by -> member removal is written as `[]`, **never `null`**. That is not a cascade -> convention: the representation binds every writer — cascade repair, form clears and -> API writes alike — so a reader of an array field never needs a null branch. The -> guarantee is pinned by `FieldSchema`, in the `multiple` doc block of -> `packages/spec/src/data/field.zod.ts` (rendered in the +> **Residual shape of a multi-value reference.** An optional `multiple: true` lookup +> emptied by member removal is stored as `null`, the same as one never set. That is +> not a cascade convention: the representation binds every writer — cascade repair, +> form clears and API writes alike, where `[]` and `""` written to an optional +> multi-value field store `null`. A reader of an array field reads `null` and `[]` +> alike as empty, as the `is_empty` filter (`$empty`) does, since a row stored before +> this rule can still hold `[]`. The rule is pinned by `FieldSchema`, in the +> `multiple` doc block of `packages/spec/src/data/field.zod.ts` (rendered in the > [Field reference](/docs/references/data/field)). > **Required foreign keys.** A `required: true` lookup cannot be nulled, so diff --git a/packages/core/src/utils/required-emptiness.ts b/packages/core/src/utils/required-emptiness.ts index 8886336ab5d..455f74beb2c 100644 --- a/packages/core/src/utils/required-emptiness.ts +++ b/packages/core/src/utils/required-emptiness.ts @@ -30,11 +30,12 @@ * - {@link isEmptyForRequired}: everything {@link isMissing} calls missing, and * — only where the DECLARED value is a set (`@objectstack/spec/data`'s * `isMultiValueField`, ADR-0104 D1) — an empty list. The #9447 maintainer - * ruling sets that half: `required` on a multi-value field means a NON-EMPTY - * list, because the empty set is representable (it reads back as `[]`, never - * `null`), so an explicit `[]` is an empty value exactly as `null` / `''` - * are. A structured-JSON field's `[]` is a legitimate document, not an - * emptied set, and is NOT empty here. + * ruling sets that half, and ruling A on #29343 (record 6591577985) keeps it: + * `required` on a multi-value field means a NON-EMPTY list, so an explicit + * `[]` is an empty value exactly as `null` / `''` are. (An emptied OPTIONAL + * multi-value field is stored as `null` by the write door; a row stored + * before that rule can still hold `[]`.) A structured-JSON field's `[]` is + * a legitimate document, not an emptied set, and is NOT empty here. * * ⛔ What a door does with "missing" is the door's: the engine skips a * runtime-owned `autonumber`, the form door judges only the entries the visitor diff --git a/packages/objectql/src/engine-cascade-delete-multivalue-probe.test.ts b/packages/objectql/src/engine-cascade-delete-multivalue-probe.test.ts index dab5d76df89..aadadaa8562 100644 --- a/packages/objectql/src/engine-cascade-delete-multivalue-probe.test.ts +++ b/packages/objectql/src/engine-cascade-delete-multivalue-probe.test.ts @@ -460,12 +460,15 @@ describe('[#9362] the dependents probe reads a multi-value reference field the w /** * [#9438] `set_null` on a SET-valued foreign key removes the deleted MEMBER - * and writes what remains; the emptied set is written as `[]`, never `null`. + * and writes what remains; an emptied set on an optional column is stored as + * `null`, the same as one never set. * * The residual-shape sentence is NOT this suite's finding: `FieldSchema` pins * it (`packages/spec/src/data/field.zod.ts`, the `multiple` and `required` - * doc blocks — #9447, maintainer ruling 2026-08-18, binding for every - * writer). This suite pins that the cascade repair CONSUMES that contract. + * doc blocks — ruling A on #29343, record 6591577985, amending #9447's + * "`[]`, never `null`" for optional columns; binding for every writer). The + * repair writes `[]` and the write door stores it as `null`, so this suite + * pins that the cascade repair CONSUMES that contract. * * The #9437 holding position — refusing these deletes 409 while the residual * shape was undecided — is REMOVED by the same change, so the success pins @@ -522,18 +525,18 @@ describe('[#9438] set_null on a multi-value reference removes the deleted member expect(stores.get('mv_explicit_set_null')?.get('h1')?.accounts).toEqual(['acc_b']); }); - it('removing the LAST member writes `[]`, never `null` — the ruled representation', async () => { + it('removing the LAST member stores `null` on an optional column — the ruled representation', async () => { const a = await engine.insert('mv_acct', { id: 'acc_a', name: 'A' }); await engine.insert('mv_zoo', { id: 'z1', name: 'z', f_lookups: ['acc_a'] }); await engine.delete('mv_acct', { where: { id: a.id } } as any); - const stored = stores.get('mv_zoo')?.get('z1')?.f_lookups; - // Assert the literal shape, both ways: the contract sentence is exactly - // "`[]`, never `null`" (field.zod.ts, `multiple` doc block). - expect(stored).toEqual([]); - expect(stored).not.toBeNull(); - expect(Array.isArray(stored)).toBe(true); + const z1 = stores.get('mv_zoo')?.get('z1'); + // Assert the literal shape: the contract sentence is "a cleared optional + // multi-value field is stored as `null`" (field.zod.ts, `multiple` doc + // block). The key is present and null, not dropped and not `[]`. + expect(z1 && 'f_lookups' in z1).toBe(true); + expect(z1?.f_lookups).toBeNull(); }); it('removal compares WHOLE members — an id keeps a member it is only a prefix of', async () => { @@ -557,7 +560,7 @@ describe('[#9438] set_null on a multi-value reference removes the deleted member await engine.delete('mv_acct', { where: { id: a.id } } as any); expect(stores.get('mv_zoo')?.get('z1')?.f_lookups).toEqual(['acc_b']); - expect(stores.get('mv_zoo')?.get('z2')?.f_lookups).toEqual([]); + expect(stores.get('mv_zoo')?.get('z2')?.f_lookups).toBeNull(); // A row that never referenced the deleted record is not touched. expect(stores.get('mv_zoo')?.get('z3')?.f_lookups).toEqual(['acc_b']); }); diff --git a/packages/objectql/src/engine-cascade-delete-user-field.test.ts b/packages/objectql/src/engine-cascade-delete-user-field.test.ts index 38c3a84e4c7..b70cd4094ad 100644 --- a/packages/objectql/src/engine-cascade-delete-user-field.test.ts +++ b/packages/objectql/src/engine-cascade-delete-user-field.test.ts @@ -330,7 +330,7 @@ describe('[#29253] a user field\'s declared deleteBehavior takes effect when the expect(driver.rowOf('uf_clear', bystander.id)).toMatchObject({ name: 'B', person: other.id }); }); - it('multi-valued: only the deleted member is removed, and an emptied set is []', async () => { + it('multi-valued: only the deleted member is removed, and an emptied optional set stores null', async () => { const { engine, driver } = await makeEngine([ holder('uf_clear_many', parsedUser({ deleteBehavior: 'set_null', multiple: true })), ]); @@ -342,7 +342,7 @@ describe('[#29253] a user field\'s declared deleteBehavior takes effect when the await engine.delete('sys_user', { where: { id: u.id }, context: SYSTEM } as any); expect(driver.rowOf('uf_clear_many', both.id)).toMatchObject({ person: [other.id] }); - expect(driver.rowOf('uf_clear_many', only.id)).toMatchObject({ person: [] }); + expect(driver.rowOf('uf_clear_many', only.id)).toMatchObject({ person: null }); }); it('a required user field\'s set_null cannot clear it, so the user delete is refused', async () => { diff --git a/packages/objectql/src/engine.ts b/packages/objectql/src/engine.ts index 366ab8abff6..9c7a432c2ec 100644 --- a/packages/objectql/src/engine.ts +++ b/packages/objectql/src/engine.ts @@ -19470,11 +19470,12 @@ export class ObjectQL implements IObjectQLEngine { * - `set_null` → clear the foreign key. On a MULTI-VALUED field * ([#18408] {@link declaredMultiValued}, ⛔ not the raw * flag) the FK is a SET, so "clear" means remove the - * deleted MEMBER and keep the rest; an emptied set is - * written as `[]`, - * never `null` — the representation `FieldSchema` pins + * deleted MEMBER and keep the rest; an emptied set on + * an optional field is stored as `null`, the same as one + * never set — the representation `FieldSchema` pins * (`packages/spec/src/data/field.zod.ts`, the `multiple` - * doc block, #9447 maintainer ruling 2026-08-18), + * doc block, ruling A on #29343, record 6591577985, + * amending #9447), * - `restrict` → refuse the delete when dependents exist. * `master_detail` defaults to `cascade` (the parent owns the child * lifecycle); `lookup` and `tree` default to `set_null` — except a `set_null` default @@ -20249,10 +20250,12 @@ export class ObjectQL implements IObjectQLEngine { // The residual shape when the last member goes is not this // engine's call to make: `FieldSchema` pins it — // `packages/spec/src/data/field.zod.ts`, the `multiple` and - // `required` doc blocks (#9447, maintainer ruling 2026-08-18) — - // and this write consumes that contract: the emptied set is - // written as `[]`, which `Array.prototype.filter` already - // yields, never `null`. + // `required` doc blocks (ruling A on #29343, record + // 6591577985, amending #9447) — and this write consumes that + // contract: it hands the write door the `[]` that + // `Array.prototype.filter` yields, and the door stores an + // emptied optional set as `null` (`blankAsNullColumns`, reach + // `'emptyList'`), the same as one never set. // // [#9688] The remainder is computed by // `remainderAfterMemberRemoval`, which the per-row required diff --git a/packages/objectql/src/validation/record-validator.test.ts b/packages/objectql/src/validation/record-validator.test.ts index 8fa3f3ab2e9..7bb43144545 100644 --- a/packages/objectql/src/validation/record-validator.test.ts +++ b/packages/objectql/src/validation/record-validator.test.ts @@ -642,9 +642,10 @@ describe('validateRecord — ADR-0113 required write contract on update', () => /** * #9476 — the enforcement half of the #9447 maintainer ruling (2026-08-18): - * `required` on a multi-value field means NON-EMPTY array. The empty set is - * representable — it reads back as `[]`, never `null` — so `required` judges - * emptiness: an explicit `[]` is an empty value exactly as `null` / `''` are. + * `required` on a multi-value field means NON-EMPTY array; that half stands + * under ruling A on #29343 (record 6591577985), which stores an emptied + * OPTIONAL multi-value field as `null`. So `required` judges emptiness: an + * explicit `[]` is an empty value exactly as `null` / `''` are. * * Scope is the spec's own multi-value predicate (`isMultiValueField`, * ADR-0104 D1): inherently-multi option types, plus multi-capable types diff --git a/packages/runtime/src/cascade-delete-multivalue-lookup-real-driver.integration.test.ts b/packages/runtime/src/cascade-delete-multivalue-lookup-real-driver.integration.test.ts index b90ad27a276..256b716d0e1 100644 --- a/packages/runtime/src/cascade-delete-multivalue-lookup-real-driver.integration.test.ts +++ b/packages/runtime/src/cascade-delete-multivalue-lookup-real-driver.integration.test.ts @@ -524,7 +524,8 @@ function declareCascadeDeleteCell(cell: DialectCell): void { // ── [#9438] Member removal on the real stack. The residual-shape // sentence these tests consume is `FieldSchema`'s, not theirs: // `packages/spec/src/data/field.zod.ts`, the `multiple` and `required` - // doc blocks (#9447, maintainer ruling 2026-08-18). Under the #9437 + // doc blocks (ruling A on #29343, record 6591577985, amending #9447 + // for optional columns). Under the #9437 // holding position each of these deletes was a 409, so the 200s below // also pin the interim escalation's removal. @@ -547,7 +548,7 @@ function declareCascadeDeleteCell(cell: DialectCell): void { expect(await real.count('zz_account')).toBe(1); }); - it('removing the LAST member stores `[]`, never `null` — the ruled representation, in the database', async () => { + it('removing the LAST member stores `null` on an optional field — the ruled representation, in the database', async () => { const { protocol, real } = await rig([ACCOUNT, FIELD_ZOO]); const a: any = await engine!.insert('zz_account', { id: 'acc_a', name: 'A' }); await engine!.insert('zz_field_zoo', { id: 'z1', name: 'z', f_lookups: ['acc_a'] }); @@ -556,12 +557,12 @@ function declareCascadeDeleteCell(cell: DialectCell): void { expect(res).toMatchObject({ id: 'acc_a', success: true }); const [row]: any[] = await real.find('zz_field_zoo', { where: { id: 'z1' } }); - // The literal shape, asserted both ways: `[]`, not `null` — this is - // the observable half of the #9447 ruling, proved against what the - // real driver actually stored and read back. - expect(row.f_lookups).toEqual([]); - expect(row.f_lookups).not.toBeNull(); - expect(Array.isArray(row.f_lookups)).toBe(true); + // The literal shape: `null`, not `[]` — the observable half of ruling + // A on #29343 (record 6591577985, amending #9447 for optional + // columns), proved against what the real driver actually stored and + // read back: the emptied set is stored as one never set. + expect(row).toHaveProperty('f_lookups'); + expect(row.f_lookups).toBeNull(); }); it('a multi-value CASCADE still deletes end to end — the P0 closes for that path', async () => { diff --git a/packages/services/service-automation/src/builtin/delete-record-predicate-walk-batched.integration.test.ts b/packages/services/service-automation/src/builtin/delete-record-predicate-walk-batched.integration.test.ts index 7027cfd177b..23ceb316253 100644 --- a/packages/services/service-automation/src/builtin/delete-record-predicate-walk-batched.integration.test.ts +++ b/packages/services/service-automation/src/builtin/delete-record-predicate-walk-batched.integration.test.ts @@ -213,10 +213,11 @@ describe('[#27004] a multi delete_record walks its matched rows as a set', () => const links = new Map((await rows(LINK.name)).map((r) => [r.id, r.parent ?? null])); expect([links.get(l600), links.get(l1000), links.get(keptLink)]).toEqual([null, null, kept]); // set_null multiple lookup: every deleted member removed, the kept one - // stays, and an emptied set is written as `[]`, never `null`. + // stays, and an emptied optional set is stored as `null`, the same as one + // never set (ruling A on #29343, record 6591577985). const tags = new Map((await rows(TAGS.name)).map((r) => [r.id, r.parents])); expect(tags.get(spanning)).toEqual([kept]); - expect(tags.get(emptied)).toEqual([]); + expect(tags.get(emptied)).toBeNull(); }); it('a required multiple lookup emptied only by the whole set refuses, naming the row whose removal empties it', async () => { From 60dc73ff71c5f729ca9ced300ae37340249de3a9 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 06:58:00 +0000 Subject: [PATCH 4/5] docs(cli): blank-single-values says the map holds four arms and why multi-value columns are out of its run Prose only. The blankAsNullColumns map now carries the 'emptyList' arm for multi_value-row columns; this run leaves them out because it takes only reach 'empty', not by the map's own rule. Claude-Session: https://claude.ai/code/session_01JQwxbWuRcoCQaZSTv51YHt Co-authored-by: Claude --- .../cli/src/commands/migrate/blank-single-values.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/packages/cli/src/commands/migrate/blank-single-values.ts b/packages/cli/src/commands/migrate/blank-single-values.ts index b2125f18182..4f98688c6dc 100644 --- a/packages/cli/src/commands/migrate/blank-single-values.ts +++ b/packages/cli/src/commands/migrate/blank-single-values.ts @@ -106,16 +106,18 @@ const SYSTEM_CTX = { isSystem: true } as const; /** * The columns of one object this run cleans: the write door's #29277 arm, read * from `blankAsNullColumns` (`@objectstack/core`, the one rule the door and - * the seed replay read). That map holds three arms; this run takes the two + * the seed replay read). That map holds four arms; this run takes the two * `'empty'` ones minus the text-like columns of the #29176 unique arm, which * leaves exactly the optional single-value columns on the `$empty` table's * `null_only` row: options, references and file references today. The text * types are the spec's `STRING_VALUE_TYPES`, the set the map itself reads for * that arm: no type name is listed here. * - * Out, by the map's own rule: `required` columns, lifecycle columns, - * `multiple: true` columns (the `multi_value` row), typed columns (the #20308 - * `'blank'` arm, whose door already refuses to store a blank as a value). + * Out, by the map's own rule: `required` columns and lifecycle columns. Out + * because this run takes only `reach === 'empty'`: `multiple: true` columns + * (the `multi_value` row), which the map holds on the #29343 `'emptyList'` + * arm, and typed columns (the #20308 `'blank'` arm, whose door already refuses + * to store a blank as a value). */ export function blankSingleValueColumns(schema: unknown): Array<{ name: string; type: string }> { const fields = (schema as { fields?: Record } | undefined)?.fields; From b702c0f1cee285eed74ff9e93e23a53c3babe785 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 08:20:05 +0000 Subject: [PATCH 5/5] fix(core): the emptyList arm skips readonly columns, so a platform-stamped [] is stored as written Ruling record 6598733139: a readonly multi-value column is platform-stamped and never cleared by a writer, so its [] is a stamped value, not a cleared field. sys_activity.changed_fields keeps [] (stamped, nothing changed) apart from null (unstamped, ADR-0139 E6). The 'blank' and 'empty' arms are unchanged. One unit pin and one changeset sentence. Claude-Session: https://claude.ai/code/session_01JQwxbWuRcoCQaZSTv51YHt Co-authored-by: Claude --- .changeset/29343-multi-value-blank-null.md | 1 + .../core/src/utils/blank-storage-form.test.ts | 17 +++++++++++++++++ packages/core/src/utils/blank-storage-form.ts | 11 ++++++++++- 3 files changed, 28 insertions(+), 1 deletion(-) diff --git a/.changeset/29343-multi-value-blank-null.md b/.changeset/29343-multi-value-blank-null.md index 48560a1c1c9..b3a635ed75c 100644 --- a/.changeset/29343-multi-value-blank-null.md +++ b/.changeset/29343-multi-value-blank-null.md @@ -14,6 +14,7 @@ A cleared `multiselect`, `checkboxes` or `tags` field, or a `select`, `radio`, ` - **Now.** The engine's write door (`insert`, `insertMany`, `update` by id and by predicate, and the `validate` dry run; every REST, batch, upsert and import door reaches it) stores `[]`, or exactly `''`, as `null` on every optional column the `$empty` table puts on its `multi_value` row. Unique or not. A non-empty list is stored as sent. - **Read change.** An optional multi-value field cleared from now on reads back `null`, not `[]`, whoever clears it: an API or form write of `[]` or `''`, and the `set_null` cascade repair that removes the last member of a multi-value reference (it used to leave `[]`). A reader that branched on `Array.isArray` alone reads `null` and `[]` alike as empty. It still matches `is_empty` / `$empty: true` (the table reads `null` and `[]` as empty on that row), and now also matches `$null: true` and `field = null`. +- **Readonly columns.** A `readonly: true` multi-value column is platform-stamped and never cleared by a writer, so a `[]` written there keeps `[]` (ruling record 6598733139): `sys_activity.changed_fields` still stores `[]` for "stamped, nothing changed" and `null` for "unstamped". - **Unchanged.** The `$empty` table itself. A `required` multi-value field given `[]` or `''` is refused `required` as before, also when it declares a `defaultValue`. Whitespace is a value. A real duplicate is still `409 UNIQUE_VIOLATION`. - **Defaults.** An optional multi-value field that declares a `defaultValue` now takes it on insert when sent `[]` or `''`, exactly as when sent `null`. - **Rows already stored.** Existing `[]` and `""` values are not migrated by this change. They keep their stored form, still answer `$empty: true`, and still collide with each other on a unique column until a data step cleans them. `os migrate blank-single-values` does not touch these columns. diff --git a/packages/core/src/utils/blank-storage-form.test.ts b/packages/core/src/utils/blank-storage-form.test.ts index 2459e4e426b..d30c9bf98b1 100644 --- a/packages/core/src/utils/blank-storage-form.test.ts +++ b/packages/core/src/utils/blank-storage-form.test.ts @@ -68,6 +68,23 @@ describe('blankAsNullColumns: the four arms of the write door\'s blank-as-null r expect(list.get('ref')).toBe('empty'); }); + it('[#29343] a readonly optional tags column written [] keeps [] — platform-stamped, never cleared (record 6598733139)', () => { + const cols = blankAsNullColumns({ + fields: { + changed_fields: { type: 'tags', required: false, readonly: true }, + tg: { type: 'tags' }, + stage_ro: { type: 'select', readonly: true, options: [{ value: 'a' }] }, + }, + }); + expect(cols.has('changed_fields')).toBe(false); + const stamped: string[] = []; + expect(blankStorageForm(stamped, cols.get('changed_fields'))).toBe(stamped); + // Controls: the same type without readonly still takes the arm, and the + // 'empty' arm is untouched by readonly. + expect(cols.get('tg')).toBe('emptyList'); + expect(cols.get('stage_ro')).toBe('empty'); + }); + it('lifecycle names are the five engine-owned columns', () => { expect([...ENGINE_LIFECYCLE_COLUMNS]).toEqual(['id', 'created_at', 'created_by', 'updated_at', 'updated_by']); }); diff --git a/packages/core/src/utils/blank-storage-form.ts b/packages/core/src/utils/blank-storage-form.ts index 2126ddfcd37..c72e164f41f 100644 --- a/packages/core/src/utils/blank-storage-form.ts +++ b/packages/core/src/utils/blank-storage-form.ts @@ -50,6 +50,12 @@ * a `defaultValue` would fill) nor a lifecycle column the engine owns * ({@link ENGINE_LIFECYCLE_COLUMNS}), and never whitespace (a value on every * row of the `$empty` table they touch). A non-empty list is never touched. + * The `'emptyList'` arm also never reaches a `readonly: true` column + * (ruling record 6598733139): a readonly column is platform-stamped and never + * cleared by a writer (a non-system write of it is stripped), so the `[]` the + * platform writes there is a stamped value, not a cleared field, and is stored + * as written — `sys_activity.changed_fields`, where `[]` is "stamped, nothing + * changed" and `null` is "unstamped" (ADR-0139 E6). */ import { @@ -125,6 +131,8 @@ export type BlankAsNullReach = 'blank' | 'empty' | 'emptyList'; export interface BlankAsNullFieldDef extends ValueShapeFieldDef { required?: boolean; unique?: unknown; + /** Platform-stamped: the `'emptyList'` arm never reaches it (record 6598733139). */ + readonly?: boolean; } /** @@ -148,7 +156,8 @@ export function blankAsNullColumns( if (LIFECYCLE.has(name) || def.required === true) continue; const { arm } = expandEmptyOperator(def); if (arm === 'multi_value') { - out.set(name, 'emptyList'); + // A readonly column keeps the `[]` the platform stamps (record 6598733139). + if (def.readonly !== true) out.set(name, 'emptyList'); continue; } const empty = UNIQUE_BLANK_TEXT_TYPES.has(def.type)