Skip to content

Commit 076b82c

Browse files
committed
fix(objectql)!: exempt summary from the numeric type check; progress keeps it
Seat ruling 5860986842 on #20308: the number branch's door is NUMERIC_VALUE_TYPES minus COMPUTED_VALUE_TYPES, so a roll-up max/min over a temporal child field recomputes as at base. The changeset declares the progress narrowing (Clause-② no, narrowing; BREAKING, minor). Claude-Session: https://claude.ai/code/session_01Bvd69VPa6puiNzzPUroDBx Co-authored-by: Claude <noreply@anthropic.com>
1 parent 7bae61b commit 076b82c

4 files changed

Lines changed: 132 additions & 54 deletions

File tree

‎.changeset/20308-blank-typed-value-null.md‎

Lines changed: 27 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,16 @@
11
---
2-
"@objectstack/objectql": patch
2+
"@objectstack/objectql": minor
33
---
44

5-
fix(objectql): a cleared number, boolean, date, datetime or time field stores `null`, on every backend (#20308)
5+
fix(objectql)!: a cleared number, boolean, date, datetime or time field stores `null` on every backend, and a `progress` field refuses a non-numeric value (#20308)
66

7-
`patch` — a bug fix in a released package. No exported symbol, no spec key and
8-
no API signature changes.
7+
Clause-②: no (narrowing)
8+
9+
**BREAKING** — shipped as `minor` under the launch-window convention
10+
(`check-changeset-no-major` refuses `major` until GA; breaking-ness is carried by
11+
this banner and the ADR-0087 disposition below, never by the level). The one
12+
narrowing: a non-numeric string written to a `progress` field is now refused
13+
with `invalid_number` on memory and SQLite, where it used to be stored.
914

1015
## What was wrong
1116

@@ -25,8 +30,9 @@ had three outcomes:
2530
objectui's edit form sends a cleared date, datetime or time box as `''`, so this
2631
is the ordinary "clear the field and save" gesture.
2732

28-
Separately, `progress` and `summary` had no type check at all: a non-numeric
29-
string such as `'abc'` was stored verbatim on memory and SQLite.
33+
Separately, `progress` had no type check at all: a non-numeric string such as
34+
`'abc'` was stored verbatim on memory and SQLite, and failed at the driver as a
35+
`500` on PostgreSQL.
3036

3137
## What changes
3238

@@ -42,28 +48,31 @@ string such as `'abc'` was stored verbatim on memory and SQLite.
4248
- **What that means for a write:** the column stores `null` on every backend,
4349
and PostgreSQL no longer refuses the request. A blank on a `required` field is
4450
refused with `required`, exactly as `null` is. On create, a blank takes the
45-
field's `defaultValue` exactly as `null` does.
51+
field's `defaultValue` exactly as `null` does, so a blank on a required field
52+
that declares a `defaultValue` is now accepted with the default.
4653
- **String-stored columns are untouched.** A text, lookup or select `''` is still
4754
stored as `''`.
48-
- **`progress` and `summary` join the numeric type check.** A non-numeric
49-
string on either is refused with `invalid_number`, as on `number`. No `min`,
50-
`max` or `scale` is newly enforced on them.
51-
- **One consequence for roll-ups.** A `summary` whose `summaryOperations` takes
52-
`min` or `max` over a `date`, `datetime` or `time` field writes a date string
53-
into the numeric summary. PostgreSQL already refused that recompute
54-
(`ERR_SUMMARY_RECOMPUTE`). Memory and SQLite now refuse it the same way,
55-
instead of storing the string.
55+
- **`progress` joins the numeric type check.** A non-numeric string on it is
56+
refused with `invalid_number`, as on `number`. No `min`, `max` or `scale` is
57+
newly enforced on it. This is the narrowing above.
58+
- **`summary` is exempt from that type check.** It is in the spec's
59+
`COMPUTED_VALUE_TYPES` ("never client-written; shape is producer-owned"), so
60+
the roll-up producer decides its value's shape. A `max` or `min` roll-up over a
61+
date, datetime or time child field keeps recomputing on memory and SQLite as
62+
before, and a non-numeric value written to a `summary` is not judged by this
63+
check. A blank on a `summary` still becomes `null`.
5664

5765
## Rows already stored
5866

5967
This fixes new writes only. Rows written earlier on SQLite (and on memory,
6068
MongoDB or libSQL) may still hold `''` in such a column; on SQLite a boolean
6169
holding `''` reads back as `false`, and one holding whitespace as `true`.
62-
PostgreSQL never stored one. To repair a
63-
SQLite table, run this once per non-string-typed column (a field of one of the
64-
types listed above):
70+
PostgreSQL never stored one. To repair a SQLite table, run this once per
71+
non-string-typed column (a field of one of the types listed above):
6572

6673
```sql
6774
UPDATE "<object>" SET "<field>" = NULL
6875
WHERE typeof("<field>") = 'text' AND trim("<field>", ' ' || char(9) || char(10) || char(13)) = '';
6976
```
77+
78+
<!-- adr-0087: not-required (no-migration-prescription) Nothing authored moves: `packages/spec` is untouched and no metadata key is added, removed or reshaped, so `objectstack migrate meta` has nothing to rewrite and the ledger has no row to gain. What is refused is a caller-written VALUE — a non-numeric string on a `progress` field — at the write door; stored rows are never re-read by the check, and a caller that sends a number or a blank is unaffected. The other categories are closed on facts: the package publishes (not `unpublished`); no ADR-0087 id covers a write-door value check (not `registered` / `already-registered`); and the change is runtime behaviour, not a TypeScript declaration (not `runtime-interface-only` / `type-surface-only`). -->

‎packages/objectql/src/validation/record-validator.blank-typed-value.test.ts‎

Lines changed: 28 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,10 @@
66
*
77
* `normalizeBlankTypedValues` rewrites `''` / whitespace on a non-string-typed
88
* column to `null` and leaves everything else alone. Both populations are read
9-
* from the spec's own sets, so a type joining `NON_TEXT_STORED_VALUE_TYPES` or
10-
* `NUMERIC_VALUE_TYPES` tomorrow is covered here without an edit — and a type
11-
* leaving the string side would turn the control half red.
9+
* from the spec's own sets, so a type joining `NON_TEXT_STORED_VALUE_TYPES`,
10+
* `NUMERIC_VALUE_TYPES` or `COMPUTED_VALUE_TYPES` tomorrow is covered here
11+
* without an edit — and a type leaving the string side would turn the control
12+
* half red.
1213
*
1314
* The engine-level half (what reaches the driver on every door) is
1415
* `../engine-blank-typed-value-door.test.ts`; the physical-column half, through
@@ -17,6 +18,7 @@
1718

1819
import { describe, it, expect } from 'vitest';
1920
import {
21+
COMPUTED_VALUE_TYPES,
2022
NON_TEXT_STORED_VALUE_TYPES,
2123
NUMERIC_VALUE_TYPES,
2224
STRING_VALUE_TYPES,
@@ -109,7 +111,12 @@ describe('normalizeBlankTypedValues (#20308)', () => {
109111
});
110112
});
111113

112-
describe('the numeric type door is NUMERIC_VALUE_TYPES (#20308)', () => {
114+
// The door the number branch reads: the numeric class minus the server-computed
115+
// class (seat ruling on #20308 — `summary` is producer-owned).
116+
const typeChecked = [...NUMERIC_VALUE_TYPES].filter((t) => !COMPUTED_VALUE_TYPES.has(t));
117+
const computedNumeric = [...NUMERIC_VALUE_TYPES].filter((t) => COMPUTED_VALUE_TYPES.has(t));
118+
119+
describe('the numeric type door is NUMERIC_VALUE_TYPES minus COMPUTED_VALUE_TYPES (#20308)', () => {
113120
function refusal(type: string, value: unknown) {
114121
try {
115122
validateRecord(schemaOf([type]), { [`f_${type}`]: value }, 'insert');
@@ -120,7 +127,14 @@ describe('the numeric type door is NUMERIC_VALUE_TYPES (#20308)', () => {
120127
}
121128
}
122129

123-
it.each([...NUMERIC_VALUE_TYPES])('%s refuses a non-numeric string with invalid_number', (type) => {
130+
it('the two populations are the ones the ruling names — progress judged, summary exempt', () => {
131+
// A control on the sets themselves: if either emptied, the cases below
132+
// would pass over nothing.
133+
expect(typeChecked.sort()).toEqual(['currency', 'number', 'percent', 'progress', 'rating', 'slider']);
134+
expect(computedNumeric).toEqual(['summary']);
135+
});
136+
137+
it.each(typeChecked)('%s refuses a non-numeric string with invalid_number', (type) => {
124138
const e = refusal(type, 'abc');
125139
expect(e?.code).toBe('VALIDATION_FAILED');
126140
expect(e?.fields.map((f) => [f.field, f.code])).toEqual([[`f_${type}`, 'invalid_number']]);
@@ -130,7 +144,15 @@ describe('the numeric type door is NUMERIC_VALUE_TYPES (#20308)', () => {
130144
expect(refusal(type, 7)).toBeNull();
131145
});
132146

133-
it('progress and summary take the type check only — no bound or scale is newly enforced', () => {
147+
it.each(computedNumeric)('%s is exempt: its shape is the producer\'s (COMPUTED_VALUE_TYPES), not this check\'s', (type) => {
148+
// What the roll-up recompute writes — a date string for a `max` over a
149+
// temporal child field — is not refused here. (A blank still becomes null
150+
// at the door; that is `normalizeBlankTypedValues`, above.)
151+
expect(refusal(type, '2026-01-05')).toBeNull();
152+
expect(refusal(type, 'abc')).toBeNull();
153+
});
154+
155+
it('progress takes the type check only, and summary none — no bound or scale is newly enforced', () => {
134156
// The boundary the branch states: a declared `max` / `scale` on these two
135157
// was never enforced, and this change does not start (a separate decision).
136158
for (const type of ['progress', 'summary']) {

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

Lines changed: 32 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ import {
6666
FILE_REFERENCE_TYPES,
6767
STRUCTURED_JSON_TYPES,
6868
NUMERIC_VALUE_TYPES,
69+
COMPUTED_VALUE_TYPES,
6970
NON_TEXT_STORED_VALUE_TYPES,
7071
percentScaleOf,
7172
} from '@objectstack/spec/data';
@@ -838,30 +839,36 @@ function validateOne(
838839
return null;
839840
}
840841

841-
// ── number types (NUMERIC_VALUE_TYPES) ──────────────────────────
842-
// The door is the SPEC'S numeric class, read as a constant for the reason the
843-
// string branch above reads `BOUNDED_STRING_FIELD_TYPES` (#11875): a type
844-
// joining the class there joins the type check here, with no second list to
845-
// forget. It was a hand-list of five until #20308 — `progress` and `summary`,
846-
// both members of the class, had no type check at all, so `'abc'` was stored
847-
// verbatim in a numeric column on memory and SQLite (and failed at the
848-
// driver, as a 500, on PostgreSQL).
849-
if (NUMERIC_VALUE_TYPES.has(t)) {
842+
// ── number types (NUMERIC_VALUE_TYPES ∖ COMPUTED_VALUE_TYPES) ────
843+
// The door is the SPEC'S numeric class minus the spec's server-computed
844+
// class, both read as constants for the reason the string branch above reads
845+
// `BOUNDED_STRING_FIELD_TYPES` (#11875): a type joining either set there moves
846+
// this door with no second list to forget. It was a hand-list of five until
847+
// #20308 — `progress`, a member of the numeric class, had no type check at
848+
// all, so `'abc'` was stored verbatim in a numeric column on memory and SQLite
849+
// (and failed at the driver, as a 500, on PostgreSQL).
850+
//
851+
// ⛔ `summary` is subtracted, by the seat ruling on #20308: it is also in
852+
// `COMPUTED_VALUE_TYPES` — 「Server-computed types: never client-written;
853+
// shape is producer-owned」 — so its value's shape is the roll-up producer's
854+
// to decide, not this caller-value check's. Judging it here refused the
855+
// producer's own write: a `max` / `min` roll-up over a temporal child field
856+
// recomputes to a date string, and the child write that triggered it then
857+
// failed with `ERR_SUMMARY_RECOMPUTE` on memory and SQLite. A blank on a
858+
// `summary` is still `null` at the door (`normalizeBlankTypedValues` reads the
859+
// whole numeric class).
860+
if (NUMERIC_VALUE_TYPES.has(t) && !COMPUTED_VALUE_TYPES.has(t)) {
850861
const n = typeof value === 'number' ? value : Number(value);
851862
if (!Number.isFinite(n)) {
852863
return fail('invalid_number');
853864
}
854-
// [#20308] `progress` and `summary` joined the TYPE check above, and only
855-
// that. The bounds and `scale` below keep the five types they always read:
856-
// - `summary` is also PLATFORM-written — the roll-up recompute stores its
857-
// aggregate through `update()` — and a platform-computed value has
858-
// nobody to refuse (the `scale` note below says so for `formula`).
859-
// - `scale`'s own contract names the types it is enforced on (`number`,
860-
// `percent`, `rating`, `slider`), and `min` / `max` on these two were
861-
// never enforced; starting to enforce either would narrow what a caller
862-
// may write, which is a separate decision from "a numeric column holds a
863-
// number".
864-
if (t === 'progress' || t === 'summary') return null;
865+
// [#20308] `progress` joined the TYPE check above, and only that. The
866+
// bounds and `scale` below keep the five types they always read: `scale`'s
867+
// own contract names the types it is enforced on (`number`, `percent`,
868+
// `rating`, `slider`), and `min` / `max` on `progress` were never enforced;
869+
// starting to enforce either would narrow what a caller may write, which is
870+
// a separate decision from "a numeric column holds a number".
871+
if (t === 'progress') return null;
865872
if (def.min !== undefined && n < def.min) {
866873
return fail('min_value', { min: def.min });
867874
}
@@ -879,9 +886,9 @@ function validateOne(
879886
// refuse — so its `scale` is applied by rounding at the producer, in
880887
// `applyFormulaPlan`. Rounding here instead would convert #7501's rejection
881888
// into the silent alteration the ruling forbids. Nothing was carved out of
882-
// #7501 to make that work: formula / autonumber outputs never reach this
883-
// branch, and a `summary` returns above, after the type check alone
884-
// (#20308), so the formula rounding fills a hole #7501 never covered.
889+
// #7501 to make that work: the type door below already excludes formula /
890+
// summary / autonumber outputs from this function's reach, so the formula
891+
// rounding fills a hole #7501 never covered.
885892
// Only a well-formed declaration (integer ≥ 0) is enforced: `scale: 2.5`
886893
// has no defined meaning, and inventing one here (floor? round?) would be
887894
// the consumer-side guessing PD #12 forbids — a malformed declaration
@@ -1090,9 +1097,8 @@ function validateOne(
10901097
return null;
10911098
}
10921099

1093-
// Remaining types (formula/autonumber outputs, json/code payloads) are
1094-
// explicitly open per the spec contract — see field-value.zod.ts. (`summary`
1095-
// is judged by the numeric branch above since #20308.)
1100+
// Remaining types (formula/summary/autonumber outputs, json/code payloads)
1101+
// are explicitly open per the spec contract — see field-value.zod.ts.
10961102
return null;
10971103
}
10981104

‎packages/rest/src/rest-data-blank-typed-value.test.ts‎

Lines changed: 45 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,14 @@
1717
* `str_empty` — an empty string must not become null); a valid value on every
1818
* typed column is stored unchanged; a required field's blank is refused as
1919
* `400 VALIDATION_FAILED` / `required`; and a non-numeric string on `progress`
20-
* or `summary` is refused as `invalid_number`.
20+
* is refused as `invalid_number`.
21+
*
22+
* `summary` is exempt from that type check (seat ruling on #20308: it is in the
23+
* spec's `COMPUTED_VALUE_TYPES`, "never client-written; shape is
24+
* producer-owned"), so a roll-up `max` over a temporal child field recomputes
25+
* as it did at base: the child write succeeds and the recompute lands. Measured
26+
* on this change before the exemption, that child write failed with
27+
* `ERR_SUMMARY_RECOMPUTE` on memory and SQLite.
2128
*/
2229

2330
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
@@ -52,6 +59,26 @@ const REQ = {
5259
},
5360
};
5461

62+
// A roll-up whose value is not a number: `max` over a temporal child field.
63+
const PARENT = {
64+
name: 'blank_rollup_parent', label: 'Parent', systemFields: false,
65+
fields: {
66+
id: { name: 'id', type: 'text' as const, primaryKey: true },
67+
latest_due: {
68+
name: 'latest_due', type: 'summary' as const,
69+
summaryOperations: { object: 'blank_rollup_child', field: 'due', function: 'max' as const, relationshipField: 'parent' },
70+
},
71+
},
72+
};
73+
const CHILD = {
74+
name: 'blank_rollup_child', label: 'Child', systemFields: false,
75+
fields: {
76+
id: { name: 'id', type: 'text' as const, primaryKey: true },
77+
parent: { name: 'parent', type: 'lookup' as const, reference: 'blank_rollup_parent' },
78+
due: { name: 'due', type: 'date' as const },
79+
},
80+
};
81+
5582
const VALID: Record<string, unknown> = {
5683
f_number: 42, f_currency: 9.5, f_percent: 0.25, f_rating: 3, f_slider: 10, f_progress: 50, f_summary: 7,
5784
f_boolean: true, f_toggle: false, f_date: '2026-09-27', f_datetime: '2026-09-27T10:00:00.000Z', f_time: '14:30:00',
@@ -90,6 +117,8 @@ async function boot() {
90117
engine.registry.registerObject(REF as any);
91118
engine.registry.registerObject(OBJ as any);
92119
engine.registry.registerObject(REQ as any);
120+
engine.registry.registerObject(PARENT as any);
121+
engine.registry.registerObject(CHILD as any);
93122
await engine.syncSchemas();
94123
await engine.insert('blank_ref', { id: 'ref1' });
95124

@@ -181,11 +210,23 @@ describe('REST write doors on SQLite: a cleared typed column stores null (#20308
181210
expect(await ctx.stored('blank_rest_req', 'q1')).toBeUndefined();
182211
});
183212

184-
it('progress and summary refuse a non-numeric string: 400 VALIDATION_FAILED / invalid_number', async () => {
185-
const res = await ctx.call('POST', '/api/v1/data/:object', { object: 'blank_rest' }, { id: 'g1', f_progress: 'abc', f_summary: 'abc' });
213+
it('progress refuses a non-numeric string: 400 VALIDATION_FAILED / invalid_number', async () => {
214+
const res = await ctx.call('POST', '/api/v1/data/:object', { object: 'blank_rest' }, { id: 'g1', f_progress: 'abc' });
186215
expect(res.status).toBe(400);
187216
expect(res.body).toMatchObject({ code: 'VALIDATION_FAILED' });
188-
expect(res.body.fields.map((x: any) => [x.field, x.code]).sort()).toEqual([['f_progress', 'invalid_number'], ['f_summary', 'invalid_number']]);
217+
expect(res.body.fields.map((x: any) => [x.field, x.code])).toEqual([['f_progress', 'invalid_number']]);
189218
expect(await ctx.stored('blank_rest', 'g1')).toBeUndefined();
190219
});
220+
221+
it('a roll-up max over a temporal child field still recomputes: the child write succeeds, as at base', async () => {
222+
await ctx.engine.insert('blank_rollup_parent', { id: 'p1' });
223+
const first = await ctx.call('POST', '/api/v1/data/:object', { object: 'blank_rollup_child' }, { id: 'k1', parent: 'p1', due: '2026-01-05' });
224+
expect(first.status).toBe(201);
225+
const second = await ctx.call('POST', '/api/v1/data/:object', { object: 'blank_rollup_child' }, { id: 'k2', parent: 'p1', due: '2026-02-07' });
226+
expect(second.status).toBe(201);
227+
expect(await ctx.stored('blank_rollup_child', 'k2')).toBeTruthy();
228+
// The recompute landed — the base answer. What a date string in a summary
229+
// column should be is a separate authoring question, not pinned here.
230+
expect((await ctx.stored('blank_rollup_parent', 'p1'))?.latest_due).toBe('2026-02-07');
231+
});
191232
});

0 commit comments

Comments
 (0)