Repository navigation
Commit bd68248
domain:server#3 · os-steve · session_01ADHkmQJdgarUjPLNgFBY67
<!-- claude-projects-attribution: {"github_login":"os-steve"} -->
_Requested by **Steve** · [project
thread](https://claude.ai/code/project/chan_01FdL1XeUyvVNxLreh3F4Qi9?thread=cmsg_01FdL1XeUyvVNxLreh3F4Qi9CGzC168tQ8WTbgReWZVSo4)_
Fixes #29277
Clause-②: yes (widening)
Metadata-protocol: none
## 维护者速读
- **改了什么**:可选的单值选项、引用、文件字段(`select` / `radio` / `lookup` /
`master_detail` / `user` / `tree` / `image` / `file` / `avatar` /
`video` / `audio`)被清空(送来 `''`)时,服务端写入口改存 `null`,唯一与否都一样。哪些类型算在内由
`$empty` 规则表的 `null_only` 行加「存一个字符串」推出,不靠手写名单。唯一字段上第二条清空的记录不再被 `409
UNIQUE_VIOLATION` 拒绝,清空的记录也能被「为空」筛选到。
- **种子重放**:服务端把空白存成 `null` 的规则(三条:#20308 类型列、#29176 唯一文本、本卡单值列)移到
`@objectstack/core` 一处(`blankAsNullColumns` /
`blankStorageForm`),写入口和种子加载器的重放比较都读它。种子里写 `''`
的行重放时不再每次被当作变更重写(主干上三条都会这样空转,已实测)。core 新增导出,所以声明改为 `Clause-②: yes
(widening)`。
- **为什么改**:维护者裁决 #29346 选 B(记录 6579813296)。`''` 既不是选项值,也不是记录
id,本来就不该算一个值;`$empty` 规则表在这些类型上只认 `null` 为空,规则表不动。
- **风险与回滚**:这些字段清空后读回的是 `null` 而不是 `''`,按 `= ''`
查询的条件对新清空的记录不再命中。必填字段照旧拒收 `''`,`multiple: true` 字段不动,已存的 `''`
不迁移(迁移另立卡)。回滚 = revert 本 PR。
-
**先看**:`packages/core/src/utils/blank-storage-form.ts`(规则本体);`packages/metadata-protocol/src/seed-loader.ts`
的 `ReplayComparands.blankAsNull` 与
`isNoOpReplay`;`packages/objectql/src/validation/record-validator.ts` 的
`normalizeBlankTypedValues`;`packages/rest/src/rest-data-single-value-blank.test.ts`;`.changeset/29277-single-value-blank-null.md`。
- **两分钟验证**:`pnpm --filter @objectstack/rest exec vitest run
src/rest-data-single-value-blank.test.ts`,预期 11 个测试全绿(主干上第二次清空是
409,`$empty` 查不到清空的记录);`pnpm --filter @objectstack/runtime exec vitest
run src/seed-blank-as-null-replay.integration.test.ts`,预期 2 个全绿(主干上重放
`totalUpdated` 为 4)。
- **需要你做的**:无,席位落地。
## What changes
- **The ruling.** Maintainer ruling B on #29346 (record 6579813296): the
write door stores `''` as `null` on every optional column whose type
sits on the `$empty` table's `null_only` row and is string-stored
single-value. The `$empty` table is unchanged; `required` keeps refusing
`''`; a blank on insert takes a declared `defaultValue`; cleaning stored
`''` is a separate migrate-lane card.
- **The seam.** `normalizeBlankTypedValues`
(`packages/objectql/src/validation/record-validator.ts`), the engine's
existing blank write door: `ObjectQL.insert()` / `insertMany()` /
`update()` (by id and by predicate) and the dry run `validate()` apply
it before anything else reads the payload, and every REST, batch, upsert
and import door reaches the engine through those methods. `engine.ts` is
not touched.
- **The new arm.** The door's blank-as-null rule is now ONE function in
`@objectstack/core` (`packages/core/src/utils/blank-storage-form.ts`):
`blankAsNullColumns(objectSchema)` maps each column to its reach,
`'blank'` (any `isMissing` blank: the #20308 typed arm,
`NON_TEXT_STORED_VALUE_TYPES`, unchanged) or `'empty'` (exactly `''`),
and `blankStorageForm(value, reach)` gives the stored form. An `'empty'`
column is not `required: true`, not a lifecycle column the engine owns
(`ENGINE_LIFECYCLE_COLUMNS`, which the validator's `SKIP_FIELDS` now
reads), and EITHER text-like and unique-covered (#29176, unchanged) OR
on the `$empty` table's `null_only` row (`expandEmptyOperator(def).arm`,
read first, so a `multiple: true` column, the `multi_value` row of
#29343, is out) AND storing one string (`ONE_STRING_STORED_TYPES`).
`normalizeBlankTypedValues` consumes it and keeps no arm logic of its
own.
- **The seed replay (round 3).** `isNoOpReplay`
(`packages/metadata-protocol/src/seed-loader.ts`) compared a seed `''`
literally with the stored `null`, so every seed row writing `''` on a
blank-as-null column read as changed and was rewritten on each replay. A
new replay comparand `ReplayComparands.blankAsNull` (built once per
dataset from the definition by `blankAsNullColumns`, beside `temporal`
and `masked`) puts the SEED side in its stored form before
`seedValueEquals`. There is no blanket `'' == null`: on a column the
door keeps `''` (plain text), a seed `''` against a stored `null` is
still a change (pinned). **Pre-existing on main:** measured on head
`071872b7` (where the #20308 and #29176 arm code and the seed loader are
byte-identical to `origin/main`), a replay of one `''` per arm rewrote 4
of 4 rows: the number, date and unique-text rows churned before this PR,
and the select row is this PR's arm. All three arms are now pinned.
- **The storage-shape set.** The spec has no predicate for "stores one
string", so `ONE_STRING_STORED_TYPES` is stated once, in core, from the
spec's classes: `SINGLE_OPTION_TYPES` (an option code),
`REFERENCE_VALUE_TYPES` (a record id), `FILE_REFERENCE_TYPES` (an opaque
`sys_file` id). A pin ties it to the table: every `FieldType` whose
expansion is `null_only` and that the typed arm does not own must sit in
exactly one of that set, `STRUCTURED_JSON_TYPES` or
`COMPUTED_VALUE_TYPES`, so a type that joins the row unclassified turns
the pin red.
- **Left out, measured on SQLite through the engine** (non-blank value,
then `''` twice, on a unique column): `json`, `composite`, `record`,
`location`, `address`, `repeater`, `vector` store an object or an array
(`{"a":1}`, `[0.1,0.2]`), so `''` is not a blank of their shape;
`formula` is never stored; `autonumber` is issued by the engine on
insert whatever the caller sent (`A-1`, `A-2`, `A-3` for a value, `''`,
`''`). The file references stored one string id (JSON-encoded on SQL:
`"img_1"`) and a second `''` was refused `DUPLICATE_RECORD`, so they
join.
- **Edges.** Exactly `''`: whitespace stays a value (on a reference
column the referential-integrity check refuses it, before and after).
Lifecycle columns (`created_by`, `updated_by`) are never rewritten.
`owner_id` (an injected optional lookup) is in the set; the security
middleware already reads `owner_id` `''` and `null` as one
(`security-plugin-authz-middleware.ts`, the `row.owner_id == null ||
row.owner_id === ''` checks), so its answer does not move.
- **New export (Clause-② widening).** `@objectstack/core` exports
`blankAsNullColumns`, `blankStorageForm`, `ENGINE_LIFECYCLE_COLUMNS` and
the types `BlankAsNullReach` / `BlankAsNullFieldDef`. The changeset adds
`@objectstack/core` and `@objectstack/metadata-protocol` patch entries.
No spec edit.
- **The fence.** Neither the `BOUNDED_STRING_FIELD_TYPES` string arm
(#29241) nor the select value-shape check (#29244) is touched.
- **Pending changeset corrected.**
`.changeset/29176-optional-unique-blank-null.md` (unreleased) said
`select` / `lookup` columns keep storing `''`. This PR makes that false
in the same release, so the sentence now says text columns keep `''` and
points at this change. That is a deliberate correction of another PR's
pending changeset, so `check-empty-changeset` reports it (see
Verification).
## Why
`''` is neither an option code nor a record id. Stored as sent, it was a
value to SQL `UNIQUE` (the second cleared record on a unique column was
refused 409) and not empty to `$empty`, whose ruled table reads only
`null` as empty on these types. The formula matcher, `having` and CEL
`isBlank` already read `''` as empty, so the SQL face now agrees with
them for every newly cleared row.
## Tests
New files:
-
`packages/objectql/src/validation/record-validator.single-value-blank.test.ts`
(8 tests): the population derived from `FieldType` and the table, with
every untyped `null_only` type classified into exactly one storage
class; the joined set (11 types) and the left-out set (9 types) pinned;
the left-out types keep `''`; `''` becomes `null` on every optional
member, unique or not; `required` (with and without a `defaultValue`)
keeps `''`; `multiple: true` is the `multi_value` row and keeps `''`;
lifecycle columns untouched; whitespace and non-blank values untouched;
purity.
- `packages/core/src/utils/blank-storage-form.test.ts` (7 tests): each
arm's reach (typed incl. required and lifecycle `created_at`; unique
text by field and by index, plain text and `password` out; one-string
`select` / `lookup` / `image` in, required / `multiple: true` / `json` /
`autonumber` / `created_by` out); the lifecycle list; an unresolvable
definition declares none; `blankStorageForm` per reach.
- `packages/runtime/src/seed-blank-as-null-replay.integration.test.ts`
(2 tests), real `ObjectQL` + SQLite `SqlDriver` + `SeedLoaderService`:
one seed `''` per arm (number, date, unique text, select) lands `null`
and a replay skips all four (`totalUpdated: 0`, `totalSkipped: 4`);
control: a plain text column stored `null` and reseeded `''` is still
updated and stores `''`.
- `packages/rest/src/rest-data-single-value-blank.test.ts` (11 tests),
real `ObjectQL` + SQLite `SqlDriver` + `RestServer` routes, physical
column read past the engine: POST `''` on all eleven joined types stores
NULL; PATCH clear on all eleven; unique `select` + `lookup` created `''`
twice and cleared by PATCH twice both land; a unique `image` created
`''` twice both land; a declared unique-index member; batch create and
`createMany`; a real duplicate still `409 UNIQUE_VIOLATION`; required
`select` / `lookup` still `400 VALIDATION_FAILED` / `required` (one with
a `defaultValue`); a blank on insert takes the declared `defaultValue`
exactly as `null` does; the `$empty` pin: on a row written `''` past the
engine, `$empty: true` answers only the newly cleared row, `$empty:
false` answers the legacy `''` row and the valued row, `= ''` answers
only the legacy row.
Forced edits to existing tests (each pinned that a `select` / `lookup`
`''` stays `''`, which the ruling reverses):
-
`packages/objectql/src/validation/record-validator.blank-typed-value.test.ts`:
the string-stored control list drops the option and reference types
(they keep their "not typed" assertion).
-
`packages/objectql/src/validation/record-validator.optional-unique-blank.test.ts`:
the "other string-stored" control is now `autonumber` only.
- `packages/objectql/src/engine-optional-unique-blank-door.test.ts`: the
unique `pick` (select) / `ref` (lookup) columns move from the kept-`''`
set to the covered-`null` set.
- `packages/objectql/src/engine-blank-typed-value-door.test.ts` and
`packages/rest/src/rest-data-blank-typed-value.test.ts`: the `c_lookup`
/ `c_select` controls now expect `null`; `c_text` still `''`.
Round 3 forced edits (no expectation changed):
-
`packages/runtime/src/seed-blank-reference-real-driver.integration.test.ts`:
the comment at the `loan-empty` assertion said the insert door "does not
null" a blank lookup; it now says an optional lookup's `''` is stored as
`null` since #29277 and the replay compares it in that form. Its
expectations (`totalUpdated: 0`, `totalSkipped: 6`) are unchanged and
now pass.
- `packages/objectql/src/federated-injected-column-readers.test.ts`:
four VOCABULARY rows (`validation/record-validator.ts#<module> ::
created_at / created_by / updated_at / updated_by`) deleted, as the
census asks when a seam use leaves the source: the lifecycle names moved
to core's `ENGINE_LIFECYCLE_COLUMNS`.
<details><summary>Red on main, green on head</summary>
The two new files were committed first (`0acdf485`) and run against
`origin/main` `96fb7b5d`'s objectql build: rest `Tests 9 failed | 2
passed (11)` (POST stored `''`; unique `[201, 409]` on create, `[200,
409]` on PATCH and the index member; batch `[true, false]`; defaults not
applied; `$empty: true` answered `[]`), unit `Tests 2 failed | 5 passed
(7)`. Two of those rest reds were my own fixture faults (a `tree` value
naming no record of its object, a whitespace lookup id the
referential-integrity check refuses), fixed in `a71af048`; the
whitespace case moved to the unit file. On head: rest 11/11, unit 8/8.
</details>
<details><summary>Ablations</summary>
Made with `scripts/ablation-replace.mjs` (anchor hit once), objectql
rebuilt, marker proven in dist by `scripts/ablation-dist-preflight.mjs`
("marker present in 4 built files"), each restored with `git checkout
HEAD`, blob hash equal to HEAD, then a final rebuild with the marker
absent from all 8 built files and the rest file green (10/10 round 1,
11/11 round 2).
| Ablation | Red |
|---|---|
| L1: the new arm off (`isSingleValueCodeColumn` never true) | rest
`Tests 8 failed \| 2 passed (10)` (every positive case, including the
`$empty` pin), unit 2 failed |
| L2: the table-row check removed (a `multiple: true` column joins) |
unit `a multiple: true column is the multi_value row, not this arm` |
| L3 (round 2): `FILE_REFERENCE_TYPES` dropped from the one-string set |
rest `Tests 3 failed \| 8 passed (11)` (POST, PATCH, unique image), unit
`"" on every optional member becomes null` |
| L4 (round 2): `STRUCTURED_JSON_TYPES` added to the one-string set |
unit `the left-out types keep ""` |
| L5 (round 3): the replay comparand off (`isNoOpReplay` compares the
raw seed value), metadata-protocol rebuilt | runtime `Tests 2 failed \|
2 passed (4)`: `seed-blank-as-null-replay` (replay `totalUpdated` 4) and
`seed-blank-reference-real-driver` `:161` (1), the CI red; the
plain-text control and the required case stay green. Restored from a
copy, blob hash equal, rebuilt, both green. |
The required / lifecycle / whitespace exclusions are the #29176 lines,
ablated on PR #29271; the required and default pins here pass on base
and head, as they must.
</details>
## Acceptance notes
- **Clause-②: yes (widening).** `@objectstack/core` gains exports (round
3, the seat's ruling 6582252856). Measured: no door refuses anything it
accepted. `''` on every ruled type was accepted (201 / 200) on base and
is on head; required refusals are identical on both. A second unique
clear moves from 409 to 201, the same direction #29176 declared `no`.
- **Read shape.** A field cleared from now on reads `null`, not `''`,
and matches `$empty: true`, `$null: true` and `= null`; it no longer
matches `= ''` (the changeset says so). Stored `''` rows are not
migrated and keep their old answers; the migrate-lane card cleans them.
- **File reference types join** (seat 3's round-2 reading of ruling B,
comment 6581612131: the rule is stated by the table plus the storage
shape). A `multiple: true` file or image column is the `multi_value` row
and stays out (#29343).
- **Not measured:** PostgreSQL, MySQL (same knex path), MongoDB (the
`mongod` download is 403 in this container).
## Verification
Head `22a77935` (`origin/main` `96fb7b5d` merged in at `e607df78`; a
full-declaration rebuild of the runtime and rest closures). CI runs the
rest: see the PR's check-runs.
<details><summary>Suites, typecheck, lint, gates</summary>
| Run | Result |
|---|---|
| core full suite + typecheck | Test Files 138 passed (138) · Tests 3021
passed · typecheck exit 0 |
| metadata-protocol full suite + typecheck | Test Files 305 passed, 4
skipped (309) · Tests 22161 passed, 26 skipped · typecheck exit 0 |
| objectql full suite + typecheck | Test Files 533 passed, 1 failed
(534) on the first run: the injected-column census (re-keyed above, then
`5 passed (5)`) · Tests 10320 · typecheck exit 0 |
| runtime full suite + typecheck | Test Files 527 passed (527) · Tests
9678 passed, 19 skipped · typecheck exit 0 |
| rest full suite + typecheck | Test Files 376 passed (376) · Tests 7329
passed, 352 skipped · typecheck exit 0 |
| `pnpm lint` | exit 0 |
| `dispatch-gates --commands` (63 derived), reconciled with `--ran` | 63
derived, 63 run, 0 NOT-MEASURED, 0 UNRUN; 62 exit 0
(`check:type-check-debt` first queue-timed out behind the suites, exit
3, then rerun under the lock: exit 0) |
| `node scripts/check-empty-changeset.mjs --base origin/main` | exit 1,
by design: the DELIBERATE CORRECTION class for
`.changeset/29176-optional-unique-blank-null.md` (see What changes). Not
restored, no `skip-changeset`; the seat confirmed. `Check Changeset` is
not a required context. |
| `check-changeset-no-major` against this body (offline `--event`) |
exit 0: level axis `Clause-②: yes (widening)` / `Metadata-protocol:
none`, patch |
The plugin-teardown self-test needed `git fetch origin 621a487…` and
`check:dual-build-cjs-loads` a full `./packages/**` build first; both
then exit 0. Full dogfood not run: no `!` in the title and no
`(narrowing)` arm.
</details>
---
_Generated by [Claude
Code](https://claude.ai/code/session_01ADHkmRPC4f86r9bWGuYWJu)_
---------
Co-authored-by: Claude <noreply@anthropic.com>
1 parent 1b2043e commit bd68248
17 files changed
Lines changed: 903 additions & 106 deletions
File tree
- .changeset
- packages
- core/src
- utils
- metadata-protocol/src
- objectql/src
- validation
- rest/src
- runtime/src
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
10 | 10 | | |
11 | 11 | | |
12 | 12 | | |
13 | | - | |
| 13 | + | |
14 | 14 | | |
15 | 15 | | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
169 | 169 | | |
170 | 170 | | |
171 | 171 | | |
| 172 | + | |
| 173 | + | |
| 174 | + | |
| 175 | + | |
| 176 | + | |
| 177 | + | |
| 178 | + | |
| 179 | + | |
172 | 180 | | |
173 | 181 | | |
174 | 182 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
| 69 | + | |
| 70 | + | |
| 71 | + | |
| 72 | + | |
| 73 | + | |
| 74 | + | |
| 75 | + | |
| 76 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
| 69 | + | |
| 70 | + | |
| 71 | + | |
| 72 | + | |
| 73 | + | |
| 74 | + | |
| 75 | + | |
| 76 | + | |
| 77 | + | |
| 78 | + | |
| 79 | + | |
| 80 | + | |
| 81 | + | |
| 82 | + | |
| 83 | + | |
| 84 | + | |
| 85 | + | |
| 86 | + | |
| 87 | + | |
| 88 | + | |
| 89 | + | |
| 90 | + | |
| 91 | + | |
| 92 | + | |
| 93 | + | |
| 94 | + | |
| 95 | + | |
| 96 | + | |
| 97 | + | |
| 98 | + | |
| 99 | + | |
| 100 | + | |
| 101 | + | |
| 102 | + | |
| 103 | + | |
| 104 | + | |
| 105 | + | |
| 106 | + | |
| 107 | + | |
| 108 | + | |
| 109 | + | |
| 110 | + | |
| 111 | + | |
| 112 | + | |
| 113 | + | |
| 114 | + | |
| 115 | + | |
| 116 | + | |
| 117 | + | |
| 118 | + | |
| 119 | + | |
| 120 | + | |
| 121 | + | |
| 122 | + | |
| 123 | + | |
| 124 | + | |
| 125 | + | |
| 126 | + | |
| 127 | + | |
| 128 | + | |
| 129 | + | |
| 130 | + | |
| 131 | + | |
| 132 | + | |
| 133 | + | |
| 134 | + | |
| 135 | + | |
| 136 | + | |
| 137 | + | |
| 138 | + | |
| 139 | + | |
| 140 | + | |
| 141 | + | |
| 142 | + | |
| 143 | + | |
| 144 | + | |
| 145 | + | |
| 146 | + | |
| 147 | + | |
| 148 | + | |
| 149 | + | |
| 150 | + | |
| 151 | + | |
| 152 | + | |
| 153 | + | |
| 154 | + | |
| 155 | + | |
| 156 | + | |
| 157 | + | |
| 158 | + | |
| 159 | + | |
| 160 | + | |
| 161 | + | |
| 162 | + | |
| 163 | + | |
| 164 | + | |
| 165 | + | |
| 166 | + | |
| 167 | + | |
| 168 | + | |
| 169 | + | |
| 170 | + | |
| 171 | + | |
| 172 | + | |
| 173 | + | |
| 174 | + | |
0 commit comments