Skip to content

Commit 8ac0b12

Browse files
committed
fix(core,objectql,metadata-protocol): one blank-as-null rule for the write door and the seed replay (#29277)
The write door's three blank-as-null arms (#20308 typed, #29176 unique text, #29277 one-string null_only) move to @objectstack/core as blankAsNullColumns / blankStorageForm. The door applies it, and the seed loader's replay compares a seed value in that stored form through a new blankAsNull replay comparand, so a seed '' the door stores as null is no longer a change on every replay. Corrects a stale comment in the seed blank-reference integration test. Claude-Session: https://claude.ai/code/session_01ADHkmRPC4f86r9bWGuYWJu Co-authored-by: Claude <noreply@anthropic.com>
1 parent 071872b commit 8ac0b12

7 files changed

Lines changed: 311 additions & 119 deletions

File tree

‎.changeset/29277-single-value-blank-null.md‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
---
22
'@objectstack/objectql': patch
3+
'@objectstack/core': patch
4+
'@objectstack/metadata-protocol': patch
35
---
46

57
fix(objectql): a cleared optional single-value `select` or `lookup` stores `null`, so a second record cleared on a unique one is no longer refused `409 UNIQUE_VIOLATION`
68

7-
Clause-②: no
9+
Clause-②: yes (widening)
810
Metadata-protocol: none
911

1012
A cleared `select`, `lookup` or single file field sent as `''` was stored as sent. `''` is neither an option code, a record id nor a file id, so it held no value, yet it was one to the store and to the `is_empty` filter: on a unique column the second record cleared was refused "A record with this value already exists", and on every column the cleared record did not match `is_empty` (`$empty`), whose ruled table reads only `null` as empty on these types.
@@ -14,3 +16,5 @@ A cleared `select`, `lookup` or single file field sent as `''` was stored as sen
1416
- **Unchanged.** The `$empty` table itself. A `required` field given `''` is refused `required` as before, also when it declares a `defaultValue`. Whitespace is a value. A `multiple: true` option or reference column keeps what it stores. A real duplicate is still `409 UNIQUE_VIOLATION`.
1517
- **Defaults.** An optional field that declares a `defaultValue` now takes it on insert when sent `''`, exactly as when sent `null`.
1618
- **Rows already stored.** Existing `''` values are not migrated by this change; they keep answering as a value (`$empty: false`, `field = ''`) until a data step cleans them.
19+
- **Seed replay.** A seed that writes `''` on such a column, or on any column whose blank the door already stored as `null` (an optional unique text column, a number / boolean / date column), is compared in that stored form, so replaying it is no longer a change on every boot. Before, the replay compared the seed's `''` with the stored `null` and rewrote the row each time (bumping `updated_at`).
20+
- **New export.** `@objectstack/core` exports the rule itself, `blankAsNullColumns` and `blankStorageForm` (with `ENGINE_LIFECYCLE_COLUMNS`), which the write door and the seed loader both read.

‎packages/core/src/index.ts‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,14 @@ export * from './utils/multi-value-storage-form.js';
169169
// engine at runtime.
170170
export * from './utils/required-emptiness.js';
171171

172+
// [#29277] …and the blank-as-null rule: which columns of an object store a
173+
// blank string as `null`, and which blanks (the #20308, #29176 and #29277
174+
// arms). `@objectstack/objectql`'s write door applies it, and
175+
// `@objectstack/metadata-protocol`'s seed replay compares a seed value in the
176+
// form it stores — one rule, here because those two do not depend on each
177+
// other.
178+
export * from './utils/blank-storage-form.js';
179+
172180
// [#21007] …and the refusal a scalar comparison gets on a field stored as a
173181
// JSON column: the operator set and the words. `driver-sql` refuses it on
174182
// `where`, and `@objectstack/objectql` on the per-aggregation `filter` it
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { describe, it, expect } from 'vitest';
4+
import { blankAsNullColumns, blankStorageForm, ENGINE_LIFECYCLE_COLUMNS } from './blank-storage-form.js';
5+
6+
const schema = {
7+
fields: {
8+
qty: { type: 'number', required: true },
9+
due: { type: 'date' },
10+
sku: { type: 'text', unique: true },
11+
code: { type: 'text' },
12+
idx_a: { type: 'text' },
13+
pw: { type: 'password', unique: true },
14+
stage: { type: 'select', options: [{ value: 'a' }] },
15+
stage_req: { type: 'select', required: true, options: [{ value: 'a' }] },
16+
tags: { type: 'select', multiple: true, options: [{ value: 'a' }] },
17+
ref: { type: 'lookup' },
18+
pic: { type: 'image' },
19+
blob: { type: 'json' },
20+
num: { type: 'autonumber' },
21+
created_by: { type: 'user' },
22+
created_at: { type: 'datetime' },
23+
},
24+
indexes: [{ fields: ['idx_a'], unique: true }, { fields: ['code'] }],
25+
};
26+
27+
describe('blankAsNullColumns: the three arms of the write door\'s blank-as-null rule', () => {
28+
const cols = blankAsNullColumns(schema);
29+
30+
it('[#20308] a typed column takes any blank, required and lifecycle included', () => {
31+
expect(cols.get('qty')).toBe('blank');
32+
expect(cols.get('due')).toBe('blank');
33+
expect(cols.get('created_at')).toBe('blank');
34+
});
35+
36+
it('[#29176] exactly "" on an optional text column a unique constraint covers', () => {
37+
expect(cols.get('sku')).toBe('empty');
38+
expect(cols.get('idx_a')).toBe('empty');
39+
expect(cols.has('code')).toBe(false);
40+
expect(cols.has('pw')).toBe(false);
41+
});
42+
43+
it('[#29277] exactly "" on an optional one-string null_only column, unique or not', () => {
44+
expect(cols.get('stage')).toBe('empty');
45+
expect(cols.get('ref')).toBe('empty');
46+
expect(cols.get('pic')).toBe('empty');
47+
for (const out of ['stage_req', 'tags', 'blob', 'num', 'created_by']) expect(cols.has(out), out).toBe(false);
48+
});
49+
50+
it('lifecycle names are the five engine-owned columns', () => {
51+
expect([...ENGINE_LIFECYCLE_COLUMNS]).toEqual(['id', 'created_at', 'created_by', 'updated_at', 'updated_by']);
52+
});
53+
54+
it('an unresolvable definition declares none', () => {
55+
expect(blankAsNullColumns(undefined).size).toBe(0);
56+
expect(blankAsNullColumns({}).size).toBe(0);
57+
});
58+
});
59+
60+
describe('blankStorageForm', () => {
61+
it('"blank" nulls "" and whitespace; "empty" nulls only ""; no reach keeps everything', () => {
62+
expect(blankStorageForm('', 'blank')).toBeNull();
63+
expect(blankStorageForm(' ', 'blank')).toBeNull();
64+
expect(blankStorageForm('', 'empty')).toBeNull();
65+
expect(blankStorageForm(' ', 'empty')).toBe(' ');
66+
expect(blankStorageForm('', undefined)).toBe('');
67+
});
68+
69+
it('a non-blank value or a non-string comes back as it is', () => {
70+
const obj = { a: 1 };
71+
for (const v of ['a', 0, false, null, undefined, obj]) {
72+
expect(blankStorageForm(v, 'blank')).toBe(v);
73+
expect(blankStorageForm(v, 'empty')).toBe(v);
74+
}
75+
});
76+
});
Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* THE blank-as-null rule — which columns of an object store a blank string as
5+
* `null`, and which blanks — read by the write door that stores it and by the
6+
* seed loader's replay, which must compare a seed value in the form the door
7+
* stores it.
8+
*
9+
* ## Two faces, one rule
10+
*
11+
* - `@objectstack/objectql`'s write door (`normalizeBlankTypedValues`) applies
12+
* it to every insert and update payload before anything else reads it.
13+
* - `@objectstack/metadata-protocol`'s seed loader asks it in `isNoOpReplay`
14+
* (the `blankAsNull` replay comparand): a seed writing `''` on such a column
15+
* lands as `null`, and compared as written it read the row as changed and
16+
* rewrote it on every boot (#29277; the #20308 and #29176 arms churned the
17+
* same way before it).
18+
*
19+
* The two packages do not depend on each other, and a copy each is how a
20+
* replay comes to disagree with the store. So the rule lives here, beside
21+
* `multiValueStorageForm` and `temporalStorageForm`, which are shared for the
22+
* same reason. The type classes and the `$empty` table it reads are the
23+
* spec's, never re-listed.
24+
*
25+
* ## The rule — three arms
26+
*
27+
* - [#20308] `'blank'`: a column whose type is non-string-typed
28+
* (`NON_TEXT_STORED_VALUE_TYPES`: numeric, boolean, `date`, `datetime`,
29+
* `time`) stores any blank (`isMissing`: `''` or whitespace) as `null` —
30+
* required or not, lifecycle or not: a blank is never a value of its type.
31+
* - [#29176] `'empty'`: exactly `''` on an optional text-like column
32+
* (`STRING_VALUE_TYPES` minus `password` / `secret`) a unique constraint
33+
* covers — its own `unique`, or a unique `indexes[]` entry, both read through
34+
* `isUniqueDeclared`, the drivers' own predicate.
35+
* - [#29277] `'empty'`: exactly `''` on an optional column on the `$empty`
36+
* table's `null_only` row (`expandEmptyOperator`) whose value is ONE string
37+
* ({@link ONE_STRING_STORED_TYPES}), unique or not (maintainer ruling B on
38+
* #29346, record 6579813296).
39+
*
40+
* The two `'empty'` arms never reach a `required: true` column (there `''` is
41+
* refused `required`, and must not first become a `null` a `defaultValue`
42+
* would fill) nor a lifecycle column the engine owns
43+
* ({@link ENGINE_LIFECYCLE_COLUMNS}), and never whitespace (a value on both
44+
* rows of the `$empty` table they touch).
45+
*/
46+
47+
import {
48+
NON_TEXT_STORED_VALUE_TYPES,
49+
STRING_VALUE_TYPES,
50+
SINGLE_OPTION_TYPES,
51+
REFERENCE_VALUE_TYPES,
52+
FILE_REFERENCE_TYPES,
53+
expandEmptyOperator,
54+
isUniqueDeclared,
55+
type ValueShapeFieldDef,
56+
} from '@objectstack/spec/data';
57+
import { isMissing } from './required-emptiness.js';
58+
59+
/**
60+
* Lifecycle columns the engine always owns and the client never supplies,
61+
* skipped by NAME because they are not author-declared business fields. The
62+
* write door's validator skips the same names (its `SKIP_FIELDS` is this list).
63+
*/
64+
export const ENGINE_LIFECYCLE_COLUMNS: readonly string[] = Object.freeze([
65+
'id', 'created_at', 'created_by', 'updated_at', 'updated_by',
66+
]);
67+
const LIFECYCLE: ReadonlySet<string> = new Set(ENGINE_LIFECYCLE_COLUMNS);
68+
69+
/**
70+
* [#29176] The text-like types of the unique arm: the spec's
71+
* `STRING_VALUE_TYPES` — exactly the "text-like" row of the `$empty` table,
72+
* where `''` and `null` already answer as one — minus the two credential
73+
* types. ⛔ `password` and `secret` stay out: a maintainer ruling refuses `''`
74+
* on them (`EmptyCredentialWriteError`) while `null` means "clear", so
75+
* rewriting the blank would turn that refusal into a silent clear.
76+
*/
77+
const UNIQUE_BLANK_TEXT_TYPES: ReadonlySet<string> = new Set(
78+
[...STRING_VALUE_TYPES].filter((t) => t !== 'password' && t !== 'secret'),
79+
);
80+
81+
/**
82+
* [#29277] The storage-shape half of the `null_only` arm: the spec's value
83+
* classes whose single value IS one string — an option code
84+
* (`SINGLE_OPTION_TYPES`), a record id (`REFERENCE_VALUE_TYPES`), an opaque
85+
* `sys_file` id (`FILE_REFERENCE_TYPES`; SQL stores it JSON-encoded, still one
86+
* string to every reader). Read as the spec's constants, never re-listed. The
87+
* spec has no predicate for "stores one string", so this set is stated here,
88+
* and `record-validator.single-value-blank.test.ts` (`@objectstack/objectql`)
89+
* ties it to the table: every type on the `null_only` row that is not typed
90+
* must be in this set or in one of the two classes left out below, so a type
91+
* that joins the row unclassified turns that pin red.
92+
*
93+
* Left out, each measured on SQLite through the engine:
94+
*
95+
* - `STRUCTURED_JSON_TYPES` (`json`, `composite`, `record`, `location`,
96+
* `address`, `repeater`, `vector`): the value is an object or an array, so
97+
* `''` is not a blank of its shape. What empty means for them is a
98+
* separate question.
99+
* - `COMPUTED_VALUE_TYPES` (`formula`, `autonumber`; `summary` is typed): the
100+
* engine owns the value — a formula is never stored, an autonumber is
101+
* issued on insert whatever the caller sent, `''` included.
102+
*/
103+
const ONE_STRING_STORED_TYPES: ReadonlySet<string> = new Set([
104+
...SINGLE_OPTION_TYPES,
105+
...REFERENCE_VALUE_TYPES,
106+
...FILE_REFERENCE_TYPES,
107+
]);
108+
109+
/**
110+
* Which blanks a column stores as `null`: `'blank'` — any blank string
111+
* (`isMissing`); `'empty'` — exactly `''`.
112+
*/
113+
export type BlankAsNullReach = 'blank' | 'empty';
114+
115+
/** The field shape the rule reads. */
116+
export interface BlankAsNullFieldDef extends ValueShapeFieldDef {
117+
required?: boolean;
118+
unique?: unknown;
119+
}
120+
121+
/**
122+
* The columns of one object whose blank the write door stores as `null`, each
123+
* with its reach. A column not in the map keeps every blank as written.
124+
*/
125+
export function blankAsNullColumns(
126+
objectSchema: { fields?: Record<string, BlankAsNullFieldDef | undefined>; indexes?: unknown } | undefined | null,
127+
): ReadonlyMap<string, BlankAsNullReach> {
128+
const out = new Map<string, BlankAsNullReach>();
129+
const fields = objectSchema?.fields;
130+
if (!fields || typeof fields !== 'object') return out;
131+
let covered: Set<string> | undefined;
132+
const isCovered = (name: string) => (covered ??= uniqueCoveredColumns(fields, objectSchema?.indexes)).has(name);
133+
for (const [name, def] of Object.entries(fields)) {
134+
if (!def || typeof def.type !== 'string') continue;
135+
if (NON_TEXT_STORED_VALUE_TYPES.has(def.type)) {
136+
out.set(name, 'blank');
137+
continue;
138+
}
139+
if (LIFECYCLE.has(name) || def.required === true) continue;
140+
const empty = UNIQUE_BLANK_TEXT_TYPES.has(def.type)
141+
? isCovered(name)
142+
: expandEmptyOperator(def).arm === 'null_only' && ONE_STRING_STORED_TYPES.has(def.type);
143+
if (empty) out.set(name, 'empty');
144+
}
145+
return out;
146+
}
147+
148+
/**
149+
* The form a value written to a column of the given reach is stored in:
150+
* `null` when the value is a blank that reach takes, else the value itself
151+
* (the same reference). `reach` `undefined` — a column not in
152+
* {@link blankAsNullColumns} — keeps every value.
153+
*/
154+
export function blankStorageForm(value: unknown, reach: BlankAsNullReach | undefined): unknown {
155+
if (reach === undefined || typeof value !== 'string') return value;
156+
if (reach === 'empty') return value === '' ? null : value;
157+
return isMissing(value) ? null : value;
158+
}
159+
160+
function uniqueCoveredColumns(fields: Record<string, BlankAsNullFieldDef | undefined>, indexes: unknown): Set<string> {
161+
const covered = new Set<string>();
162+
for (const [name, def] of Object.entries(fields)) {
163+
if (def && isUniqueDeclared(def.unique)) covered.add(name);
164+
}
165+
if (Array.isArray(indexes)) {
166+
for (const idx of indexes) {
167+
if (!idx || typeof idx !== 'object' || Array.isArray(idx)) continue;
168+
const { unique, fields: members } = idx as { unique?: unknown; fields?: unknown };
169+
if (!isUniqueDeclared(unique) || !Array.isArray(members)) continue;
170+
for (const name of members) if (typeof name === 'string') covered.add(name);
171+
}
172+
}
173+
return covered;
174+
}

‎packages/metadata-protocol/src/seed-loader.ts‎

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ import { resolveSeedRecord } from '@objectstack/formula';
3333
import {
3434
bulkWrite, withTransientRetry, defaultIsTransientError, type BulkWriteRowResult, runWithAdvisoryAggregation, type AdvisoryGroup,
3535
temporalComparandKind, temporalStorageForm, type TemporalComparandKind, isMissing,
36+
blankAsNullColumns, blankStorageForm, type BlankAsNullReach,
3637
} from '@objectstack/core';
3738
// [#8442] The repo's ONE recogniser for "this throw is a record-validation
3839
// failure" — duck-typed on `code`/`name`, the same predicate `mapDataError` and
@@ -556,6 +557,12 @@ interface ReplayComparands {
556557
readonly temporal: ReadonlyMap<string, TemporalComparandKind>;
557558
/** [#24624] Fields the read path masks: a set value is never compared. */
558559
readonly masked: ReadonlySet<string>;
560+
/**
561+
* [#29277] Fields whose blank the write door stores as `null`, with which
562+
* blanks (`blankAsNullColumns`, `@objectstack/core` — the rule the door
563+
* itself applies): a seed value is compared in that stored form.
564+
*/
565+
readonly blankAsNull: ReadonlyMap<string, BlankAsNullReach>;
559566
}
560567

561568
/**
@@ -1467,10 +1474,12 @@ export class SeedLoaderService implements ISeedLoaderService {
14671474
this.warnOnUndeclaredSeedColumns(dataset, objectDefinition);
14681475
// How a replay compares each field (`isNoOpReplay`): [#24562] a temporal
14691476
// field in its stored form, [#24624] a field the read path masks never by
1470-
// its set value.
1477+
// its set value, [#29277] a seed blank the write door stores as `null` as
1478+
// that `null`.
14711479
const comparands: ReplayComparands = {
14721480
temporal: temporalFieldKinds(objectDefinition),
14731481
masked: maskedOnReadFields(objectDefinition),
1482+
blankAsNull: blankAsNullColumns(objectDefinition),
14741483
};
14751484
// [#25779] The dataset's file fields: seed files stored and kept, and an
14761485
// id-shaped file value that names no file refused (`seed-files.ts`).
@@ -3114,6 +3123,15 @@ export class SeedLoaderService implements ISeedLoaderService {
31143123
* change. No unmasked read is taken for this; the loader never sees a
31153124
* stored credential.
31163125
*
3126+
* [#29277] A seed BLANK is compared in the form the write door stores it
3127+
* (`comparands.blankAsNull`, from `blankAsNullColumns` in `@objectstack/core`
3128+
* — the rule the door itself applies, never a second spelling of it): a
3129+
* seed `''` on an optional `select` / `lookup`, on an optional unique text
3130+
* column, or any blank on a number / boolean / date column lands as `null`,
3131+
* so against a stored `null` it is no change. Only the SEED side is put in
3132+
* stored form, and only on those columns: elsewhere `''` against `null` is
3133+
* still a change, because there the door keeps `''`.
3134+
*
31173135
* Pure: {@link decideWriteAction} asks it up to three times per record.
31183136
*/
31193137
private isNoOpReplay(
@@ -3124,7 +3142,8 @@ export class SeedLoaderService implements ISeedLoaderService {
31243142
for (const [key, value] of Object.entries(record)) {
31253143
if (key === 'id') continue;
31263144
if (SeedLoaderService.isUncomparableCredential(key, value, existing[key], comparands)) continue;
3127-
if (!this.seedValueEquals(value, existing[key], comparands.temporal.get(key))) return false;
3145+
const seedStored = blankStorageForm(value, comparands.blankAsNull.get(key));
3146+
if (!this.seedValueEquals(seedStored, existing[key], comparands.temporal.get(key))) return false;
31283147
}
31293148
return true;
31303149
}

0 commit comments

Comments
 (0)