From 542d69afd1a4b2b830cbea1c8a068540c0274d83 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 16:22:58 +0000 Subject: [PATCH 1/3] fix(objectql)!: a select refuses a plain object or an array as its value, and as a multi-value element (#29244) A single-value select or radio with no options returned before looking at the value, so an object or an array reached the driver as sent; a declared select admitted ['a'] through String(['a']). The arm now refuses both shapes with invalid_type ahead of option membership, and the multi-value option branch refuses an element that is an object or an array. The two #5922 pins that rested on the admission are restated as refusals that assert the shape sentence, not the operator sentence. Claude-Session: https://claude.ai/code/session_01ADHkmPFEXGDQbc7fNcM24e Co-authored-by: Claude --- .changeset/29244-select-object-array-value.md | 14 ++ .../operator-object-write-value.test.ts | 32 +-- .../record-validator.select-shape.test.ts | 202 ++++++++++++++++++ .../src/validation/record-validator.ts | 44 +++- .../rest/src/rest-data-select-shape.test.ts | 118 ++++++++++ 5 files changed, 393 insertions(+), 17 deletions(-) create mode 100644 .changeset/29244-select-object-array-value.md create mode 100644 packages/objectql/src/validation/record-validator.select-shape.test.ts create mode 100644 packages/rest/src/rest-data-select-shape.test.ts diff --git a/.changeset/29244-select-object-array-value.md b/.changeset/29244-select-object-array-value.md new file mode 100644 index 00000000000..d2127eb5cfe --- /dev/null +++ b/.changeset/29244-select-object-array-value.md @@ -0,0 +1,14 @@ +--- +'@objectstack/objectql': patch +--- + +fix(objectql)!: a select refuses a plain object or an array as its value, and as an element of a multi-value select's array + +Clause-②: no (narrowing) +Metadata-protocol: none + + + +**BREAKING** for a data write that sends an object or an array where a select expects an option value. FROM: `{ stage: { a: 1 } }` or `{ stage: ["x"] }` on a single-value `select` / `radio` with no `options` answered `201` / `200` and stored the object or the array as sent (SQLite as its JSON text); so did `{ stage: ["a"] }` on a select whose options include `a`, and `{ labels: [{ a: 1 }] }` or `{ labels: [["a"]] }` on a `multiple: true` select, `multiselect`, `checkboxes` or `tags` field. TO: each is refused `400 VALIDATION_FAILED` with the field's `invalid_type` ("expected one option value, received an object", or "expected an array of option values, received an element that is an array"), on create, update by id, update by filter and the `validate()` dry run, and nothing is stored. The fix for a caller that hits it: send the option code itself (`{ stage: "x" }`), or an array of codes on a multi-value field (`{ labels: ["a"] }`); a value that really is structured data belongs in a `json` field. + +One refusal changes its code: an object on a select whose declared `options` do not include it used to answer `invalid_option` and now answers `invalid_type`, because its shape is wrong before its membership is. Unchanged: strings and numbers on a single-value select, arrays of scalars on a multi-value one, option membership, a lone scalar wrapped on a multi-value field, `null`, and a filter-operator object (still refused with its own sentence). Rows already stored with an object or array value are not rewritten. diff --git a/packages/objectql/src/validation/operator-object-write-value.test.ts b/packages/objectql/src/validation/operator-object-write-value.test.ts index 78d30692432..da0cbb6b528 100644 --- a/packages/objectql/src/validation/operator-object-write-value.test.ts +++ b/packages/objectql/src/validation/operator-object-write-value.test.ts @@ -132,20 +132,28 @@ describe('#5922 — what the rule deliberately leaves alone', () => { expect(errs[0].message).toMatch(/array/i); }); - it('admits an UNDECLARED $-spelling — not a filter any backend executes', () => { - // The rule is derived from the spec's operator vocabulary, not from the - // `$` sigil. `{ $inn: … }` is out of scope by construction; judging every - // plain object on a scalar field is #5922's option A, which this is not. - // - // [#29133] Pinned on a free-form `select`, not on `text`: the text family - // now refuses every plain object by its own shape rule (see - // `record-validator.text-shape.test.ts`), which would make this pin pass - // for a reason other than the one it states. - expect(() => validateRecord(schema, { stage_free: { $inn: ['a'] } }, 'update')).not.toThrow(); + // The rule is derived from the spec's operator vocabulary, not from the `$` + // sigil: `{ $inn: … }` is out of its scope by construction, and judging every + // plain object on a scalar field is #5922's option A, which this is not. + // + // [#29244] Restated as refusals. Every scalar-valued type now refuses a plain + // object by its own SHAPE rule (the text family since #29133, the + // single-value select since #29244), so no scalar field is left that admits + // one, and moving the pins to `json` would pin nothing: that column skips + // this rule entirely. What the pins still prove is that THIS rule did not + // claim the object. The shape sentence answers, never the operator sentence. + it('an UNDECLARED $-spelling is not named as a filter operator (the shape rule refuses it)', () => { + const errs = errorsOf({ stage_free: { $inn: ['a'] } }); + expect(errs.map((e) => [e.field, e.code])).toEqual([['stage_free', 'invalid_type']]); + expect(errs[0].constraint).toMatchObject({ detail: 'expected one option value, received an object' }); + expect(errs[0].message).not.toContain('filter operator'); }); - it('admits a plain non-operator object (same reason)', () => { - expect(() => validateRecord(schema, { stage_free: { a: 1 } }, 'update')).not.toThrow(); + it('a plain non-operator object is not named as a filter operator (same reason)', () => { + const errs = errorsOf({ stage_free: { a: 1 } }); + expect(errs.map((e) => [e.field, e.code])).toEqual([['stage_free', 'invalid_type']]); + expect(errs[0].constraint).toMatchObject({ detail: 'expected one option value, received an object' }); + expect(errs[0].message).not.toContain('filter operator'); }); }); diff --git a/packages/objectql/src/validation/record-validator.select-shape.test.ts b/packages/objectql/src/validation/record-validator.select-shape.test.ts new file mode 100644 index 00000000000..83e76a733e0 --- /dev/null +++ b/packages/objectql/src/validation/record-validator.select-shape.test.ts @@ -0,0 +1,202 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#29244] A select's value is an option code: one for a single-value + * `select` / `radio`, an array of them for a multi-value one. A plain object or + * an array is refused as the single value, and as an element of the array. + * + * Measured on `origin/main` @ `04e0c2c0` before the change, with a recording + * driver behind the real engine, on insert, update by id and update by + * predicate alike: + * + * field value before after + * select, no options { a: 1 } ADMIT, driver got { a: 1 } invalid_type + * select, no options (and radio) ['x'] ADMIT, driver got ['x'] invalid_type + * select, options a/b ['a'] ADMIT, driver got ['a'] invalid_type + * select, options a/b { a: 1 } invalid_option invalid_type + * select multiple, no options [{ a: 1 }] ADMIT invalid_type + * select multiple, no options [['x']] ADMIT invalid_type + * select multiple, options a/b [['a']] ADMIT, driver got [['a']] invalid_type + * multiselect / tags / checkboxes, free [{ a: 1 }] ADMIT invalid_type + * + * A string, a number, an array of scalars on a multi-value field, and option + * membership are unchanged. + */ + +import { describe, it, expect } from 'vitest'; +import { validateRecord, validateRecordInScope, ValidationError } from './record-validator.js'; +import { ObjectQL } from '../engine.js'; + +const schema = { + fields: { + free: { type: 'select', label: 'Free' }, + free_radio: { type: 'radio', label: 'Free radio' }, + decl: { type: 'select', label: 'Stage', options: [{ value: 'a' }, { value: 'b' }] }, + free_multi: { type: 'select', label: 'Free multi', multiple: true }, + decl_multi: { type: 'select', label: 'Stages', multiple: true, options: [{ value: 'a' }, { value: 'b' }] }, + ms: { type: 'multiselect', label: 'MS' }, + tags: { type: 'tags', label: 'Tags' }, + boxes: { type: 'checkboxes', label: 'Boxes' }, + }, +}; + +function errorsOf(data: Record, mode: 'insert' | 'update' = 'insert') { + try { + validateRecord(schema, data, mode); + } catch (e) { + if (e instanceof ValidationError) return e.fields; + throw e; + } + return []; +} + +describe('#29244 — a single-value select refuses a plain object or an array', () => { + for (const field of ['free', 'free_radio', 'decl'] as const) { + for (const [shape, value, received] of [ + ['object', { a: 1 }, 'an object'], + ['empty object', {}, 'an object'], + ['array', ['a'], 'an array'], + ['empty array', [], 'an array'], + ] as const) { + for (const mode of ['insert', 'update'] as const) { + it(`${field}: refuses ${shape} on ${mode} as invalid_type`, () => { + const errs = errorsOf({ [field]: value }, mode); + expect(errs.map((e) => [e.field, e.code])).toEqual([[field, 'invalid_type']]); + expect(errs[0].constraint).toMatchObject({ + type: schema.fields[field].type, + detail: `expected one option value, received ${received}`, + }); + }); + } + } + } + + it('says the field, the type and the received shape in the message', () => { + const errs = errorsOf({ free: { a: 1 } }); + expect(errs[0].message).toContain('Free'); + expect(errs[0].message).toContain('select'); + expect(errs[0].message).toContain('expected one option value, received an object'); + }); + + it('refuses the shape on a readonly select too (a shape arm, reached under the readonly scope)', () => { + const ro = { fields: { kind: { type: 'select', label: 'Kind', readonly: true } } }; + let err: unknown = null; + try { + validateRecordInScope(ro, { kind: ['x'] }, 'insert', 'include'); + } catch (e) { + err = e; + } + expect(err).toBeInstanceOf(ValidationError); + expect((err as ValidationError).fields.map((f) => [f.field, f.code])).toEqual([['kind', 'invalid_type']]); + }); + + it('a filter-operator object keeps its own, earlier sentence', () => { + const errs = errorsOf({ free: { $in: ['a'] } }, 'update'); + expect(errs).toHaveLength(1); + expect(errs[0].message).toContain('$in is a filter operator, not a value'); + }); +}); + +describe('#29244 — a multi-value option field refuses an element that is a plain object or an array', () => { + for (const field of ['free_multi', 'decl_multi', 'ms', 'tags', 'boxes'] as const) { + for (const [shape, value, received] of [ + ['object element', ['a', { a: 1 }], 'an object'], + ['array element', [['a']], 'an array'], + ] as const) { + it(`${field}: refuses an ${shape} as invalid_type`, () => { + const errs = errorsOf({ [field]: value }); + expect(errs.map((e) => [e.field, e.code])).toEqual([[field, 'invalid_type']]); + expect(errs[0].constraint).toMatchObject({ + detail: `expected an array of option values, received an element that is ${received}`, + }); + }); + } + } +}); + +describe('#29244 — what the rule leaves alone', () => { + it('a scalar on a single-value select is judged as before', () => { + expect(errorsOf({ free: 'anything', free_radio: 'x', decl: 'a' })).toEqual([]); + expect(errorsOf({ free: 3 })).toEqual([]); + expect(errorsOf({ decl: 'z' }).map((e) => e.code)).toEqual(['invalid_option']); + }); + + it('an array of scalars on a multi-value field is judged as before', () => { + expect(errorsOf({ free_multi: ['x', 'y'], decl_multi: ['a', 'b'], ms: ['x'], tags: ['t'], boxes: [] })).toEqual([]); + expect(errorsOf({ decl_multi: ['z'] }).map((e) => e.code)).toEqual(['invalid_option']); + }); + + it('a lone scalar on a multi-value select is still wrapped, and a lone object still answers invalid_type_array', () => { + expect(errorsOf({ free_multi: 'x' })).toEqual([]); + const errs = errorsOf({ free_multi: { a: 1 } }); + expect(errs.map((e) => e.code)).toEqual(['invalid_type']); + expect(errs[0].message).toMatch(/array/i); + expect(errs[0].message).not.toContain('option values'); + }); + + it('null and undefined are unchanged', () => { + expect(errorsOf({ free: null, free_multi: null }, 'update')).toEqual([]); + expect(errorsOf({ free: undefined }, 'insert')).toEqual([]); + }); +}); + +/** + * The end-to-end half: the engine's create, update-by-id and update-by-predicate + * doors reach the rule, answer the VALIDATION_FAILED envelope, and the driver + * is never written. + */ +describe('#29244 — write path end to end: no object or array reaches the driver', () => { + async function boot() { + const engine = new ObjectQL(); + const writes: unknown[] = []; + const rows = new Map>([['rec_1', { id: 'rec_1', stage: 'orig' }]]); + engine.registerDriver({ + name: 'recording', version: '0.0.0', supports: {}, + async connect() {}, async disconnect() {}, async checkHealth() { return true; }, + async execute() { return null; }, + async find() { return Array.from(rows.values()); }, + async findOne(_o: string, ast: any) { return rows.get(ast?.where?.id) ?? null; }, + async create(_o: string, data: Record) { writes.push(data); return { ...data, id: 'x' }; }, + async update(_o: string, id: string, data: Record) { writes.push(data); return { ...data, id }; }, + async updateMany(_o: unknown, _a: unknown, data: unknown) { writes.push(data); return 0; }, + async delete() { return true; }, async count() { return rows.size; }, + async bulkCreate() { return []; }, async bulkUpdate() { return []; }, async bulkDelete() {}, + async beginTransaction() { return { commit: async () => {}, rollback: async () => {} }; }, + async commit() {}, async rollback() {}, + } as any, true); + await engine.init(); + engine.registry.registerObject({ + name: 'shape_deal', label: 'Deal', + fields: { + id: { name: 'id', label: 'ID', type: 'text', primaryKey: true }, + stage: { name: 'stage', label: 'Stage', type: 'select' }, + labels: { name: 'labels', label: 'Labels', type: 'select', multiple: true }, + }, + } as any); + return { engine, writes, rows }; + } + + it('refuses on insert, update and multi-update, and the driver sees no write', async () => { + const { engine, writes, rows } = await boot(); + for (const [field, run] of [ + ['stage', () => engine.insert('shape_deal', { stage: { a: 1 } } as any)], + ['stage', () => engine.update('shape_deal', { stage: ['x'] } as any, { where: { id: 'rec_1' } } as any)], + ['stage', () => engine.update('shape_deal', { stage: { a: 1 } } as any, { multi: true } as any)], + ['labels', () => engine.insert('shape_deal', { labels: [{ a: 1 }] } as any)], + ['labels', () => engine.update('shape_deal', { labels: [['x']] } as any, { where: { id: 'rec_1' } } as any)], + ] as const) { + const err = await run().then(() => null, (e: unknown) => e as ValidationError); + expect(err).toBeInstanceOf(ValidationError); + expect(err!.code).toBe('VALIDATION_FAILED'); + expect(err!.fields.map((f) => [f.field, f.code])).toEqual([[field, 'invalid_type']]); + } + expect(writes).toEqual([]); + expect(rows.get('rec_1')).toEqual({ id: 'rec_1', stage: 'orig' }); + }); + + it('a scalar and an array of scalars on the same path still land', async () => { + const { engine, writes } = await boot(); + await engine.update('shape_deal', { stage: 'won', labels: ['x', 'y'] } as any, { where: { id: 'rec_1' } } as any); + expect(writes).toEqual([expect.objectContaining({ stage: 'won', labels: ['x', 'y'] })]); + }); +}); diff --git a/packages/objectql/src/validation/record-validator.ts b/packages/objectql/src/validation/record-validator.ts index b39c42d05db..aa6befa836e 100644 --- a/packages/objectql/src/validation/record-validator.ts +++ b/packages/objectql/src/validation/record-validator.ts @@ -54,7 +54,9 @@ * - format email / url / phone (lightweight RFC-aware regex) * - select / multiselect: value must appear in `options`, or be one the write * names for the field in `keptOptionValues` (an import's kept - * cell, #22183) + * cell, #22183); a single value that is a plain object or an + * array, or an array element that is one, is `invalid_type` + * whether or not `options` are declared (#29244) * - boolean / toggle: must coerce to boolean * - date / datetime: must be ISO-parsable, naming a year from 0001 to 9999 * for a `date` and from 1000 to 9999 for a `datetime` (#20280); @@ -682,8 +684,8 @@ export function normalizeMultiValueFields( * the declared type at all", with the same wire code and the same sentence a * non-readonly field gets: a `date` / `datetime` / `time` the platform reads, * a number for a number-typed field, a boolean, an array for a multi-value - * field, no plain object or array on a string-typed field, no filter-operator - * object, and the ADR-0104 reference / media / structured-JSON shape under + * field, no plain object or array on a string-typed field or as a select's + * value or option element, no filter-operator object, and the ADR-0104 reference / media / structured-JSON shape under * the object's own posture (warn-first until the deployment's evidence says * otherwise). It is never REQUIRED. * @@ -1687,8 +1689,26 @@ function validateOne( // question from two authorities that happen to agree today, which is the // drift the one-definition ruling (#17469) closes. if ((t === 'select' || t === 'radio') && !isMultiValueField(def)) { - // Option membership is the whole arm, and it is a constraint: ⛔ never on - // a readonly value (the open-vocabulary ruling, see ReadonlyValueScope). + // [#29244] A single-value select stores ONE option code (the spec's + // `valueSchemaFor`: a declared code, or a string when no `options` are + // declared). A plain object or an array is refused as a shape, never + // judged by its string reading: with no `options` the arm returned before + // looking at the value, so `{ a: 1 }` and `['x']` reached the driver as + // sent; with `options`, `['a']` passed membership as `String(['a'])` and + // the driver stored the array. Neither the list nor the filter door (which + // refuses an object or array comparand on this field) can read either. + // Same posture and sentence as the string arm's #29133 rule. A SHAPE test, + // so it runs ahead of `shapeOnly` and of option membership; a filter + // operator object was already refused above, with its own sentence. + if (isPlainObjectValue(value) || Array.isArray(value)) { + return fail( + 'invalid_type', + { type: t, detail: `expected one option value, received ${Array.isArray(value) ? 'an array' : 'an object'}` }, + 'invalid_value_shape', + ); + } + // Option membership is the rest of the arm, and it is a constraint: ⛔ never + // on a readonly value (the open-vocabulary ruling, see ReadonlyValueScope). if (shapeOnly) return null; const allowed = optionValues(def.options); if (picklist !== undefined && allowed.length === 0) return picklistUnresolved(); @@ -1728,6 +1748,20 @@ function validateOne( // including a system write. Whether a referenced id EXISTS is still // `assertReferencesResolve`'s question, with its own `isSystem` latitude. if (!isValueShapeJudgedType(t)) { + // [#29244] Each element is one option code, as for a single value. An + // element that is a plain object or an array is refused as a shape: with + // no `options` this branch admitted any array, and with `options` a + // nested `['a']` passed membership as `String(['a'])`. A shape test, so + // ahead of `shapeOnly`; an array of scalars is judged as before. + const nested = value.find((v) => isPlainObjectValue(v) || Array.isArray(v)); + if (nested !== undefined) { + const received = Array.isArray(nested) ? 'an array' : 'an object'; + return fail( + 'invalid_type', + { type: t, detail: `expected an array of option values, received an element that is ${received}` }, + 'invalid_value_shape', + ); + } if (shapeOnly) return null; const allowed = optionValues(def.options); if (allowed.length === 0) { diff --git a/packages/rest/src/rest-data-select-shape.test.ts b/packages/rest/src/rest-data-select-shape.test.ts new file mode 100644 index 00000000000..c81eeed8150 --- /dev/null +++ b/packages/rest/src/rest-data-select-shape.test.ts @@ -0,0 +1,118 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#29244] A plain object or an array sent as a select's value, through the + * REST write doors, on a real engine (`ObjectQL` + sqlite `SqlDriver`) and the + * real `RestServer` routes (the harness `rest-data-text-scalar-value.test.ts` + * boots). + * + * Measured on `origin/main` @ `04e0c2c0`, on SQLite and on driver-memory: + * `POST` and `PATCH` with `{ stage: { a: 1 } }` or `{ stage: ['x'] }` on a + * select with no `options` answered 201 / 200 and stored the object or array + * (SQLite as its JSON text, driver-memory as the value); `{ labels: [{ a: 1 }] }` + * on a `multiple: true` select likewise. Both now answer 400 + * `VALIDATION_FAILED` with the field's `invalid_type`, and nothing is stored. + * + * Controls: a string and an array of strings are stored as sent. + */ + +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 OBJ = { + name: 'select_rest', label: 'Select', systemFields: false, + fields: { + id: { name: 'id', type: 'text' as const, primaryKey: true }, + stage: { name: 'stage', type: 'select' as const }, + labels: { name: 'labels', type: 'select' as const, multiple: true }, + }, +}; + +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(); + engine.registry.registerObject(OBJ 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 }; + }; + /** The physical row, read past every engine read coercion. */ + const stored = async (id: string) => + (await (driver as any).knex('select_rest').where({ id }).first()) as Record | undefined; + return { engine, call, stored }; +} + +function expectRefused(res: { status: number; body: any }, field: string) { + expect(res.status, JSON.stringify(res.body)).toBe(400); + expect(res.body?.error?.code ?? res.body?.code).toBe('VALIDATION_FAILED'); + expect(JSON.stringify(res.body)).toContain(field); + expect(JSON.stringify(res.body)).toContain('invalid_type'); +} + +describe('REST write doors: a select refuses a plain object or an array as its value (#29244)', () => { + let ctx: Awaited>; + beforeEach(async () => { ctx = await boot(); }); + + for (const [field, value] of [['stage', { a: 1 }], ['stage', ['x']], ['labels', [{ a: 1 }]]] as const) { + it(`POST create with ${field}: ${JSON.stringify(value)} is refused 400, and no row is written`, async () => { + const res = await ctx.call('POST', '/api/v1/data/:object', { object: 'select_rest' }, { id: 'c1', [field]: value }); + expectRefused(res, field); + expect(await ctx.stored('c1')).toBeUndefined(); + }); + + it(`PATCH update with ${field}: ${JSON.stringify(value)} is refused 400, and the row is unchanged`, async () => { + await ctx.engine.insert('select_rest', { id: 'u1', stage: 'open', labels: ['a'] }); + const before = await ctx.stored('u1'); + const res = await ctx.call('PATCH', '/api/v1/data/:object/:id', { object: 'select_rest', id: 'u1' }, { [field]: value }); + expectRefused(res, field); + expect(await ctx.stored('u1')).toEqual(before); + }); + } + + it('control: a string and an array of strings are stored as sent', async () => { + const res = await ctx.call('POST', '/api/v1/data/:object', { object: 'select_rest' }, { id: 's1', stage: 'won', labels: ['x', 'y'] }); + expect(res.status, JSON.stringify(res.body)).toBe(201); + const row = (await ctx.engine.findOne('select_rest', { where: { id: 's1' } })) as Record; + expect(row.stage).toBe('won'); + expect(row.labels).toEqual(['x', 'y']); + }); +}); From 3ffa21c9de78aee8ddd86b0bce888d7af84ead6d Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 16:43:33 +0000 Subject: [PATCH 2/3] test(objectql): pin the multi-value non-array sentence at the validator and the lone-scalar wrap on the write path (#29244) Claude-Session: https://claude.ai/code/session_01ADHkmPFEXGDQbc7fNcM24e Co-authored-by: Claude --- .../record-validator.select-shape.test.ts | 23 ++++++++++++------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/packages/objectql/src/validation/record-validator.select-shape.test.ts b/packages/objectql/src/validation/record-validator.select-shape.test.ts index 83e76a733e0..fa5d442f899 100644 --- a/packages/objectql/src/validation/record-validator.select-shape.test.ts +++ b/packages/objectql/src/validation/record-validator.select-shape.test.ts @@ -126,12 +126,15 @@ describe('#29244 — what the rule leaves alone', () => { expect(errorsOf({ decl_multi: ['z'] }).map((e) => e.code)).toEqual(['invalid_option']); }); - it('a lone scalar on a multi-value select is still wrapped, and a lone object still answers invalid_type_array', () => { - expect(errorsOf({ free_multi: 'x' })).toEqual([]); - const errs = errorsOf({ free_multi: { a: 1 } }); - expect(errs.map((e) => e.code)).toEqual(['invalid_type']); - expect(errs[0].message).toMatch(/array/i); - expect(errs[0].message).not.toContain('option values'); + it('a value that is not an array on a multi-value select keeps its existing invalid_type_array sentence', () => { + // (The engine wraps a lone scalar before this validator runs; the write + // path test below lands one.) + for (const value of ['x', { a: 1 }]) { + const errs = errorsOf({ free_multi: value }); + expect(errs.map((e) => e.code)).toEqual(['invalid_type']); + expect(errs[0].message).toMatch(/must be an array/i); + expect(errs[0].message).not.toContain('option values'); + } }); it('null and undefined are unchanged', () => { @@ -194,9 +197,13 @@ describe('#29244 — write path end to end: no object or array reaches the drive expect(rows.get('rec_1')).toEqual({ id: 'rec_1', stage: 'orig' }); }); - it('a scalar and an array of scalars on the same path still land', async () => { + it('a scalar, an array of scalars and a lone scalar (wrapped) on the same path still land', async () => { const { engine, writes } = await boot(); await engine.update('shape_deal', { stage: 'won', labels: ['x', 'y'] } as any, { where: { id: 'rec_1' } } as any); - expect(writes).toEqual([expect.objectContaining({ stage: 'won', labels: ['x', 'y'] })]); + await engine.insert('shape_deal', { stage: 'open', labels: 'x' } as any); + expect(writes).toEqual([ + expect.objectContaining({ stage: 'won', labels: ['x', 'y'] }), + expect.objectContaining({ stage: 'open', labels: ['x'] }), + ]); }); }); From 9e1353cf6db3104702361d24f87be86687794298 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 18:45:23 +0000 Subject: [PATCH 3/3] test(objectql): mark the number-on-select control as the present-behaviour pin #29562 moves (#29244) Claude-Session: https://claude.ai/code/session_01ADHkmPFEXGDQbc7fNcM24e Co-authored-by: Claude --- .../src/validation/record-validator.select-shape.test.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/objectql/src/validation/record-validator.select-shape.test.ts b/packages/objectql/src/validation/record-validator.select-shape.test.ts index fa5d442f899..ff6c28c86f6 100644 --- a/packages/objectql/src/validation/record-validator.select-shape.test.ts +++ b/packages/objectql/src/validation/record-validator.select-shape.test.ts @@ -117,6 +117,9 @@ describe('#29244 — a multi-value option field refuses an element that is a pla describe('#29244 — what the rule leaves alone', () => { it('a scalar on a single-value select is judged as before', () => { expect(errorsOf({ free: 'anything', free_radio: 'x', decl: 'a' })).toEqual([]); + // Present behaviour, not the contract: the spec's stored element for a + // free-form select is a string, and #29562 is meant to move this pin (a + // number or boolean on a single-value select). expect(errorsOf({ free: 3 })).toEqual([]); expect(errorsOf({ decl: 'z' }).map((e) => e.code)).toEqual(['invalid_option']); });