Skip to content

Commit 85ea224

Browse files
fix(objectql)!: a string-typed field refuses a Date, a Map, a Set, a class instance or a typed array (#29645) (#29684)
<!-- claude-projects-attribution: {"github_login":"os-warren"} --> _Requested by **Warren** · [project thread](https://claude.ai/code/project/chan_01U5RdnAAmkSQmZbqh6FtqZ8?thread=cmsg_01U5RdnAAmkSQmZbqh6FtqZ8N5m96xEWaAue5HPrCydye4)_ Fixes #29645 Clause-②: no (narrowing) Metadata-protocol: none Skills-docs: none — no file under `skills/**` or `content/docs/**` teaches writing a `Date`, `Map`, `Set`, class instance or typed array into a text-family field (`git grep` of both trees: zero hits), and the refusal reuses the existing catalog sentence. ## 维护者速读 - **改了什么**:文本类字段(text、textarea、email、url、phone 等 12 种)收到 `Date`、`Map`、`Set`、类实例或类型化数组时,现在一律以 `invalid_type` 拒收,与普通对象、数组已有的拒收同一句话;判定改用选项字段(#29562)已在用的 `isObjectValue`,没有复制新函数。 - **为什么改**:原来的对象判定只认普通对象,其它对象按字符串读法放行:内存驱动把对象原样存下,SQLite 把 `Date` 存成纪元数字 `"1767312000000.0"`,`Map`/`Set` 存成 `"{}"`;email/url/phone 只是碰巧被格式校验拦下。 - **风险与回滚**:是收窄,只影响引擎写入口(流程、钩子、服务端代码);JSON 请求体带不出这些值,REST 调用方无变化。email/url/phone 上这类对象的错误码由格式码变为 `invalid_type`。旧行不改写。回滚即 revert 本 PR。 - **先看**:`packages/objectql/src/validation/record-validator.ts` 字符串分支 `isObjectValue(value)` 那一处;`record-validator.text-object-value.test.ts`;`packages/rest/src/engine-sqlite-text-object-value.test.ts`。 - **两分钟验证**:`pnpm --filter @objectstack/objectql exec vitest run --maxWorkers=2 src/validation/record-validator.text-object-value.test.ts`,预期 140 个用例全绿。 - **需要你做的**:无,席位落地。 ## What changes - `packages/objectql/src/validation/record-validator.ts`, the `BOUNDED_STRING_FIELD_TYPES` arm: its shape test was `isPlainObjectValue(value) || Array.isArray(value)`, which is prototype-strict, so a `Date`, `Map`, `Set`, class instance or typed array passed on its string reading. It now asks `isObjectValue(value)`, the option arms' test from #29562, and names the shape with that card's `receivedObjectShape`: no copy of either. The wire code (`invalid_type`), the message key (`invalid_value_shape`) and the detail (`expected a string, received an object` / `an array`) are the ones a plain object already got. It is still a shape test, ahead of `shapeOnly`, so a readonly field's value is judged the same way. - Docblocks only, same file: the file header's string-types line, the `isObjectValue` / `receivedObjectShape` docblocks, and the #29241 "not touched" paragraph of `normalizeNumericStringValues` now say "any object". `normalizeBlankTypedRow` is untouched. - Changeset `.changeset/29645-text-field-refuses-any-object.md`: `@objectstack/objectql` patch, BREAKING banner, both declaration lines, ADR-0087 `not-required (no-migration-prescription)`, FROM / TO and the remedy. ## Why Measured on `origin/main` @ `7dae3b3c` (base) and on this branch (head `2e8ba408`), through the engine's insert, update by id and update by predicate, on SQLite (the physical column, read with the driver's query builder) and driver-memory (the engine's read). Twelve types × five values × three doors × two drivers: | value | base, 9 types (text, textarea, password, markdown, html, richtext, code, signature, qrcode): SQLite stored | base, those 9: memory read | base, email / url / phone | head, all 12, both drivers, all 3 doors | |:---|:---|:---|:---|:---| | `new Date(...)` | `"1767312000000.0"` | the `Date` itself | `invalid_email` / `invalid_url` / `invalid_phone` | `invalid_type` | | `new Map(...)` | `"{}"` | the `Map` | same format codes | `invalid_type` | | `new Set(...)` | `"{}"` | the `Set` | same format codes | `invalid_type` | | a class instance `{ k: 1 }` | `'{"k":1}'` | the instance | same format codes | `invalid_type` | | `new Uint8Array([1, 2])` | `'{"0":1,"1":2}'` | the typed array | same format codes | `invalid_type` | | plain-string control | stored as sent | as sent | as sent (a valid address, URL, phone) | stored as sent | `password` reads back masked on both sides, so its stored column is the only answer there. The format types refused by accident: `String(value)` failed their regex. Head refuses the shape first on all twelve, so those three answer `invalid_type` instead of their format code. ## Tests - New `packages/objectql/src/validation/record-validator.text-object-value.test.ts` (140 cases): the spec set's twelve members pinned by name; each type × `Date` / `Map` / `Set` / class instance / typed array × insert / update refuses with `code` + `constraint.type` + detail; a per-type plain-string control (a valid address, URL and phone on the three format types); the message names field, type and shape; the readonly scope refuses; a number and a boolean keep #29241 (written as their string, then judged); the door leaves an object as sent; a `json` field still holds a `Date` or a `Map`; engine insert, insertMany, update by id and update by predicate answer `VALIDATION_FAILED` and the driver is never written, with a string control that is. - New `packages/rest/src/engine-sqlite-text-object-value.test.ts` (6 cases): the same five values on a real `ObjectQL` + sqlite `SqlDriver`, all three doors refused, the physical row absent or unchanged; control: a string stored as sent and a number stored as `"42"`. - No existing test changed. ## Acceptance notes - Reach is the engine doors only (flows, hooks, server code), as the card says; REST over JSON cannot carry these values, so no REST pin is added. - The `currency` arm's `isPlainObjectValue` (sentence choice only, never the verdict) is untouched and is the helper's one remaining caller. - Out of scope, noted not filed: none found. ## Verification All readings on head `2e8ba408`, base `7dae3b3c`. <details><summary>Suites</summary> - `@objectstack/objectql` test: Test Files 544 passed (544), Tests 10741 passed (10741); lock VERDICT command-exit 0. - `@objectstack/rest` test: Test Files 381 passed (381), Tests 7130 passed, 332 skipped (7462); lock VERDICT command-exit 0. - `@objectstack/objectql` and `@objectstack/rest` typecheck (both include `check:test-typecheck`): lock VERDICT command-exit 0. - `@objectstack/dogfood` (full battery, narrowing arm), after building the `@objectstack/dogfood^...` closure: Test Files 399 passed, 3 skipped (402); Tests 3297 passed, 28 skipped (3325); lock VERDICT command-exit 0. No failure. - `pnpm lint` (`eslint . --no-inline-config`): exit 0. </details> <details><summary>Ablation</summary> `scripts/ablation-replace.mjs` (child mode, trap-armed) put the guard back to `if (isPlainObjectValue(value) || Array.isArray(value)) {`: anchor 1 → 0, replacement 0 → 1, blob `7b789eee` → `61b1bb58`. The objectql file (validator imported from `src`) ran red: Tests 123 failed, 17 passed (140); the 17 green are the controls and leave-alone cases. The objectql build was then run so the rest pin would read the mutation: its JS emitted and its DTS step failed (the mutated line hands `unknown` to `receivedObjectShape`), and `ablation-dist-preflight` found the marker in 4 built files. The rest pin ran red: Tests 5 failed, 1 passed (6), the green one being the control. Restore: blob == HEAD `7b789eee`, `git diff HEAD` empty; rebuild exit 0, `ablation-dist-preflight --absent` clean on all 14 built files and the tree; the rest pin back to 6/6. </details> <details><summary>Gates</summary> `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` at `2e8ba408` derived 60 families; all 60 ran, each exit 0. `--ran` reconciliation: 60 derived, 60 run, 0 NOT-MEASURED, 0 UNRUN (`pnpm lint` recorded outside the derivation). Four first answered a container prerequisite and were rerun green: `check-plugin-teardown-shape --self-test` exit 3 (after `git fetch --depth=1` of its fixture commit), `check:dts-closure` exit 1 (a dist mid-rebuild by the concurrent dogfood closure build; 225/225 declaration files on rerun), `check:type-check-debt` exit 3 (verify-lock queue timeout behind the dogfood run; rerun under the lock), `check:dual-build-cjs-loads` exit 3 (seven unbuilt packages; rerun after the full build). CI families: this PR's check-runs. </details> ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic (no `packages/spec` hunk). - [x] 2. Every name I added uses the casing for its kind. - [x] 3. No formatter ran; `pnpm lint` passes. - [x] 4. One issue, one branch off an up-to-date `main`, one pull request. - [x] 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset. - [x] 6. `packages/spec` touched: n/a, untouched. - [x] 7. No hand edits to `content/docs/references/`, `packages/*/CHANGELOG.md` or `content/docs/releases/`. - [x] 8. Title, body and comments are in English (the maintainer brief is Chinese by the repo's rule). - [x] The *Before Submitting* list in CONTRIBUTING.md is done. --- _Generated by [Claude Code](https://claude.ai/code/session_01JQwxbWsjv939FeRpCY83MV)_ Co-authored-by: Claude <noreply@anthropic.com>
1 parent 772b356 commit 85ea224

4 files changed

Lines changed: 321 additions & 11 deletions

File tree

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
'@objectstack/objectql': patch
3+
---
4+
5+
fix(objectql)!: a string-typed field refuses a Date, a Map, a Set, a class instance or a typed array as its value, as it already refused a plain object or an array (#29645)
6+
7+
Clause-②: no (narrowing)
8+
Metadata-protocol: none
9+
10+
<!-- adr-0087: not-required (no-migration-prescription) No key of any schema is added, removed, renamed or re-shaped, and no export or type changes: what a metadata author writes is read exactly as before. Only a record write carrying an object of the wrong shape on a text-family field is refused, with the validator's existing invalid_type code and the existing ADR-0104 shape sentence, so there is nothing for objectstack migrate meta to rewrite and no tombstone. Rows already stored are not rewritten by this change, so no stored-row migration is prescribed either. -->
11+
12+
**BREAKING** accept-set narrowing at the record write door (`@objectstack/objectql`), on the engine's insert, insertMany, update by id, update by predicate and the `validate()` dry run. Those are the doors flows, hooks and server code write through; a JSON request body carries none of these values, so REST callers see no change.
13+
14+
- **Before.** The string arm refused a plain object or an array, but its object test was prototype-strict, so any other object passed on its string reading. On a `text`, `textarea`, `password`, `markdown`, `html`, `richtext`, `code`, `signature` or `qrcode` field, driver-memory stored the object itself and SQLite stored a `Date` as its epoch number in TEXT (`"1767312000000.0"`), a `Map` or a `Set` as `"{}"`, and a class instance or a typed array as its JSON text. On `email`, `url` and `phone` the same write was refused, but only because that string failed the format check (`invalid_email`, `invalid_url`, `invalid_phone`).
15+
- **Now.** Every object is refused `invalid_type` with the sentence `FIELD has an invalid TYPE value: expected a string, received an object`, on all twelve types, before any bound or format is judged, so the three format types now answer `invalid_type` for an object instead of their format code. The driver is never written. A readonly field's value is judged the same way, as a shape check.
16+
- **Unchanged.** A string. A number and a boolean, which keep their string reading and are stored as that string. `null` and a missing value. A plain object and an array (already refused). A filter-operator object keeps its own earlier refusal sentence. A `json` field and the structured-JSON types still hold any object.
17+
18+
**The remedy.** Write the string you mean: `value.toISOString()` for a `Date` on a text field (or declare the field `datetime`), `JSON.stringify(...)` when JSON text is really what the field should hold, or declare the field `json` when it holds structured data.
Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#29645] A string-typed field (`BOUNDED_STRING_FIELD_TYPES`) refuses ANY
5+
* object as its value, not only a plain object or an array (#29133).
6+
*
7+
* Measured on `origin/main` @ `7dae3b3c` before the change, through the engine
8+
* (insert, update by id, update by predicate), on SQLite (the physical column)
9+
* and on driver-memory (the engine's read; it keeps what it is handed):
10+
*
11+
* value on text / textarea / markdown / base SQLite (stored) base memory (read) head
12+
* html / richtext / code / signature /
13+
* qrcode / password
14+
* new Date(…) "1767312000000.0" the Date itself invalid_type
15+
* new Map(…), new Set(…) "{}" the Map / the Set invalid_type
16+
* a class instance { k: 1 } '{"k":1}' the instance invalid_type
17+
* new Uint8Array([1, 2]) '{"0":1,"1":2}' the typed array invalid_type
18+
* value on email / url / phone invalid_email / _url / _phone (by accident) invalid_type
19+
*
20+
* (`password` reads back masked, so its stored column is the only answer.)
21+
* The arm's object test was `isPlainObjectValue`, which is prototype-strict,
22+
* so these fell through to the string reading; on the three format types that
23+
* reading failed the format regex, an accident of `String(value)`. The arm
24+
* now asks the option arms' test (`isObjectValue`, #29562), so every object is
25+
* refused with the same wire code and sentence as a plain one. A string, a
26+
* number and a boolean keep #29241's rule: judged, and stored, as a string.
27+
*/
28+
29+
import { describe, it, expect } from 'vitest';
30+
import { BOUNDED_STRING_FIELD_TYPES } from '@objectstack/spec/data';
31+
import { normalizeNumericStringValues, validateRecord, validateRecordInScope, ValidationError } from './record-validator.js';
32+
import { ObjectQL } from '../engine.js';
33+
34+
const STRING_TYPES = [...BOUNDED_STRING_FIELD_TYPES];
35+
36+
class Widget {
37+
k = 1;
38+
}
39+
40+
const NON_PLAIN: ReadonlyArray<readonly [string, () => unknown]> = [
41+
['a Date', () => new Date('2026-01-02T00:00:00Z')],
42+
['a Map', () => new Map([['a', 1]])],
43+
['a Set', () => new Set(['a'])],
44+
['a class instance', () => new Widget()],
45+
['a typed array', () => new Uint8Array([1, 2])],
46+
];
47+
48+
/** A string each type's own format admits: the plain-string control. */
49+
const CONTROL: Record<string, string> = { email: 'a@b.co', url: 'https://example.com', phone: '+15551234567' };
50+
const controlFor = (type: string) => CONTROL[type] ?? 'plain';
51+
52+
function errorsOf(
53+
schema: { fields: Record<string, any> },
54+
data: Record<string, unknown>,
55+
mode: 'insert' | 'update',
56+
) {
57+
try {
58+
validateRecord(schema, data, mode);
59+
} catch (e) {
60+
if (e instanceof ValidationError) return e.fields;
61+
throw e;
62+
}
63+
return [];
64+
}
65+
66+
describe('#29645 — a string-typed field refuses any object, not only a plain one', () => {
67+
it('reads the spec set, which still holds the twelve members this pin was written against', () => {
68+
expect(STRING_TYPES.sort()).toEqual(
69+
['code', 'email', 'html', 'markdown', 'password', 'phone', 'qrcode', 'richtext', 'signature', 'text', 'textarea', 'url'],
70+
);
71+
});
72+
73+
for (const type of STRING_TYPES) {
74+
const schema = { fields: { f: { type, label: 'F' } } };
75+
for (const [shape, make] of NON_PLAIN) {
76+
for (const mode of ['insert', 'update'] as const) {
77+
it(`${type}: refuses ${shape} on ${mode} as invalid_type`, () => {
78+
const errs = errorsOf(schema, { f: make() }, mode);
79+
expect(errs.map((e) => [e.field, e.code])).toEqual([['f', 'invalid_type']]);
80+
expect(errs[0].constraint).toMatchObject({ type, detail: 'expected a string, received an object' });
81+
});
82+
}
83+
}
84+
85+
it(`${type}: control — a plain string still lands`, () => {
86+
expect(errorsOf(schema, { f: controlFor(type) }, 'insert')).toEqual([]);
87+
expect(errorsOf(schema, { f: controlFor(type) }, 'update')).toEqual([]);
88+
});
89+
}
90+
91+
it('says the field, the type and the received shape in the message', () => {
92+
const errs = errorsOf({ fields: { title: { type: 'text', label: 'Title' } } }, { title: new Date(0) }, 'insert');
93+
expect(errs[0].message).toContain('Title');
94+
expect(errs[0].message).toContain('text');
95+
expect(errs[0].message).toContain('expected a string, received an object');
96+
});
97+
98+
it('refuses it on a readonly field too (a shape arm, reached under the readonly scope)', () => {
99+
const schema = { fields: { stamp: { type: 'text', label: 'Stamp', readonly: true } } };
100+
let err: unknown = null;
101+
try {
102+
validateRecordInScope(schema, { stamp: new Date(0) }, 'insert', 'include');
103+
} catch (e) {
104+
err = e;
105+
}
106+
expect(err).toBeInstanceOf(ValidationError);
107+
expect((err as ValidationError).fields.map((f) => [f.field, f.code])).toEqual([['stamp', 'invalid_type']]);
108+
});
109+
});
110+
111+
describe('#29645 — what the rule leaves alone', () => {
112+
const schema = { fields: { title: { type: 'text', label: 'Title', maxLength: 5 }, payload: { type: 'json', label: 'P' } } };
113+
114+
it('a number and a boolean keep #29241: written as their string, then judged as one', () => {
115+
const row = normalizeNumericStringValues(schema, { title: 42, payload: 1 });
116+
expect(row).toEqual({ title: '42', payload: 1 });
117+
expect(errorsOf(schema, row, 'insert')).toEqual([]);
118+
expect(normalizeNumericStringValues(schema, { title: true }).title).toBe('true');
119+
expect(errorsOf(schema, { title: 123456 }, 'insert')[0].code).toBe('max_length');
120+
});
121+
122+
it('the door leaves an object as sent: the arm, not the door, refuses it', () => {
123+
const row = { title: new Date(0) };
124+
expect(normalizeNumericStringValues(schema, row)).toBe(row);
125+
});
126+
127+
it('a json field still holds a Date or a Map (outside the set)', () => {
128+
expect(errorsOf(schema, { payload: new Date(0) }, 'insert')).toEqual([]);
129+
expect(errorsOf(schema, { payload: new Map() }, 'insert')).toEqual([]);
130+
});
131+
});
132+
133+
/** The driver-facing half: every engine door reaches the rule and writes nothing. */
134+
describe('#29645 — write path end to end: the driver is never written', () => {
135+
async function boot() {
136+
const engine = new ObjectQL();
137+
const writes: unknown[] = [];
138+
const rows = new Map<string, Record<string, unknown>>([['rec_1', { id: 'rec_1', title: 'orig' }]]);
139+
engine.registerDriver({
140+
name: 'recording', version: '0.0.0', supports: {},
141+
async connect() {}, async disconnect() {}, async checkHealth() { return true; },
142+
async execute() { return null; },
143+
async find() { return Array.from(rows.values()); },
144+
async findOne(_o: string, ast: any) { return rows.get(ast?.where?.id) ?? null; },
145+
async create(_o: string, data: unknown) { writes.push(data); return { ...(data as object), id: 'x' }; },
146+
async update(_o: string, id: string, data: unknown) { writes.push(data); return { ...(data as object), id }; },
147+
async updateMany(_o: unknown, _a: unknown, data: unknown) { writes.push(data); return 0; },
148+
async delete() { return true; }, async count() { return rows.size; },
149+
async bulkCreate(_o: string, data: unknown[]) { writes.push(...data); return data.map((d, i) => ({ ...(d as object), id: `b${i}` })); },
150+
async bulkUpdate() { return []; }, async bulkDelete() {},
151+
async beginTransaction() { return { commit: async () => {}, rollback: async () => {} }; },
152+
async commit() {}, async rollback() {},
153+
} as any, true);
154+
await engine.init();
155+
engine.registry.registerObject({
156+
name: 'text_object', label: 'Text object',
157+
fields: {
158+
id: { name: 'id', label: 'ID', type: 'text', primaryKey: true },
159+
title: { name: 'title', label: 'Title', type: 'text' },
160+
mail: { name: 'mail', label: 'Mail', type: 'email' },
161+
},
162+
} as any);
163+
return { engine, writes, rows };
164+
}
165+
166+
it('insert, insertMany, update by id and update by predicate refuse it VALIDATION_FAILED', async () => {
167+
const { engine, writes, rows } = await boot();
168+
for (const [field, run] of [
169+
['title', () => engine.insert('text_object', { title: new Date(0) } as any)],
170+
['title', () => engine.insert('text_object', [{ title: 'ok' }, { title: new Set(['a']) }] as any)],
171+
['title', () => engine.update('text_object', { title: new Map() } as any, { where: { id: 'rec_1' } } as any)],
172+
['title', () => engine.update('text_object', { title: new Widget() } as any, { multi: true } as any)],
173+
['mail', () => engine.insert('text_object', { mail: new Uint8Array([1]) } as any)],
174+
] as const) {
175+
const err = await run().then(() => null, (e: unknown) => e as ValidationError);
176+
expect(err).toBeInstanceOf(ValidationError);
177+
expect(err!.code).toBe('VALIDATION_FAILED');
178+
expect(err!.fields.map((f) => [f.field, f.code])).toEqual([[field, 'invalid_type']]);
179+
}
180+
expect(writes).toEqual([]);
181+
expect(rows.get('rec_1')).toEqual({ id: 'rec_1', title: 'orig' });
182+
});
183+
184+
it('control: a plain string reaches the driver as sent', async () => {
185+
const { engine, writes } = await boot();
186+
await engine.insert('text_object', { title: 'plain', mail: 'a@b.co' } as any);
187+
await engine.update('text_object', { title: 'again' } as any, { where: { id: 'rec_1' } } as any);
188+
expect(writes).toEqual([
189+
expect.objectContaining({ title: 'plain', mail: 'a@b.co' }),
190+
expect.objectContaining({ title: 'again' }),
191+
]);
192+
});
193+
});

‎packages/objectql/src/validation/record-validator.ts‎

Lines changed: 21 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,9 @@
2323
* is refused on every field whose declared value is a SCALAR
2424
* — a `where` clause pasted into the SET half of a write
2525
* (#5922).
26-
* - string types (`BOUNDED_STRING_FIELD_TYPES`) a plain object or an array
27-
* is `invalid_type`, never stringified (#29133); a number or
26+
* - string types (`BOUNDED_STRING_FIELD_TYPES`) any object (a plain object,
27+
* an array, a `Date`, a `Map`, a class instance) is
28+
* `invalid_type`, never stringified (#29133, #29645); a number or
2829
* a boolean is judged, and stored, as its string reading
2930
* (#29241). Then, on that string:
3031
* - `maxLength` / `minLength`
@@ -886,16 +887,17 @@ function isPlainObjectValue(value: unknown): boolean {
886887

887888
/**
888889
* [#29562] Any object: a plain object, an array, a `Date`, a `Map`, a class
889-
* instance. The option arms' shape test. An option code is a string, and no
890-
* object has a string reading a caller meant as one (`String(new Date())` is
891-
* not a code), so the option arms refuse every object rather than only the
892-
* plain ones {@link isPlainObjectValue} names.
890+
* instance. The option arms' shape test, and [#29645] the string arm's. An
891+
* option code or a text value is a string, and no object has a string reading
892+
* a caller meant as one (`String(new Date())` is neither a code nor a title),
893+
* so those arms refuse every object rather than only the plain ones
894+
* {@link isPlainObjectValue} names.
893895
*/
894896
function isObjectValue(value: unknown): value is object {
895897
return typeof value === 'object' && value !== null;
896898
}
897899

898-
/** [#29562] The received-shape word an option arm's refusal names. */
900+
/** [#29562] The received-shape word an option or string arm's refusal names. */
899901
function receivedObjectShape(value: object): string {
900902
return Array.isArray(value) ? 'an array' : 'an object';
901903
}
@@ -969,8 +971,8 @@ function receivedObjectShape(value: object): string {
969971
* `42`.
970972
*
971973
* ⛔ Not touched: a string (judged and written as itself), `null` and a missing
972-
* key, a plain object or an array (the arm refuses them, #29133), every other
973-
* non-scalar, and every field outside the set — `json` and the structured
974+
* key, an object of any kind (the arm refuses it, #29133 / #29645), every
975+
* other non-scalar, and every field outside the set — `json` and the structured
974976
* types, the number and boolean fields and the reference types. The option
975977
* types have their own half, below.
976978
*
@@ -1328,10 +1330,18 @@ function validateOne(
13281330
// [#29241] `normalizeNumericStringValues` has already written that string
13291331
// into the payload at the door, so the driver stores what was judged; a
13301332
// filter-operator object was already refused above, with its own sentence.
1331-
if (isPlainObjectValue(value) || Array.isArray(value)) {
1333+
//
1334+
// [#29645] Every object, not only a plain one, by the option arms' test
1335+
// (#29562): a `Date`, a `Map`, a `Set`, a class instance or a typed array
1336+
// passed on its string reading, so driver-memory stored the object itself
1337+
// and SQLite stored a `Date` as its epoch number and the rest as their
1338+
// JSON text (`{}` for a `Map` or a `Set`). On `email`, `url` and `phone`
1339+
// the format check refused that string by accident; the shape is now
1340+
// refused first, on all twelve types alike. It is never stringified.
1341+
if (isObjectValue(value)) {
13321342
return fail(
13331343
'invalid_type',
1334-
{ type: t, detail: `expected a string, received ${Array.isArray(value) ? 'an array' : 'an object'}` },
1344+
{ type: t, detail: `expected a string, received ${receivedObjectShape(value)}` },
13351345
// The ADR-0104 shape sentence the operator refusal above also uses:
13361346
// one catalog entry for "this value's SHAPE is wrong for this type".
13371347
'invalid_value_shape',

0 commit comments

Comments
 (0)