Repository navigation
Commit 8209a2f
fix(objectql): a cleared optional multi-value field stores null, so a second record cleared on a unique one is no longer refused 409 (#29837)
<!-- claude-projects-attribution: {"github_login":"os-warren"} -->
_Requested by **Warren** · [project
thread](https://claude.ai/code/project/chan_01U5RdnAAmkSQmZbqh6FtqZ8?thread=cmsg_01U5RdnAAmkSQmZbqh6FtqZ87zVbP3nBfJDyeiMeBBoJsW)_
Fixes #29343
Clause-②: yes (widening)
Metadata-protocol: none
## 维护者速读
- 改了什么:可选的多值字段(multiselect、tags、checkboxes,及 `multiple: true` 的 lookup /
user / select / file / image)写入 `[]` 或 `''` 时,写入口统一存成 `null`;级联
`set_null` 删光最后一个成员时也读回 `null`。
- 为什么改:`[]` 被存成 JSON 文本 `'[]'`,唯一约束下第二条清空的记录被拒 409;按裁决 A(记录
6591577985)执行,spec 半边已由 #29763 落地。
- 风险与回滚:读形状变化,原来读回 `[]` 的可选多值字段改读 `null`;`$empty` 两者都算空,只按
`Array.isArray` 判断的读者要处理 `null`。回滚即 revert 本 PR。
- 先看:`packages/core/src/utils/blank-storage-form.ts`(新 reach
`'emptyList'`)、`packages/objectql/src/validation/record-validator.ts` 的
`normalizeBlankTypedRow`、`packages/objectql/src/engine-cascade-delete-multivalue-probe.test.ts`(钉子从
`[]` 改为 `null`)。
- 两分钟验证:`pnpm --filter @objectstack/rest exec vitest run
src/rest-data-multi-value-blank.test.ts`,预期 15/15 通过(main 上 12 条红)。
- 需要你做的:无,席位落地。
## What changes
- **Write door** (cherry-picked from the built branch
`claude/project-thread-tfknlu`, `eb81c1f6` → `ce827aa2`, `55aa2656` →
`f147106e`): `blankAsNullColumns` in `@objectstack/core` gains the reach
`'emptyList'` for every optional column on the `$empty` table's
`multi_value` row, and `blankStorageForm` stores `[]`, or exactly `''`,
there as `null`. The objectql door's row walk (`normalizeBlankTypedRow`)
now hands an empty list to the rule. Required columns, whitespace and
non-empty lists are untouched; the `$empty` table is unchanged.
- **Pins moved to `null`** (`f0afc329`), the cascade `set_null` repair
that empties an optional set now reads back `null` through the door:
- `packages/objectql/src/engine-cascade-delete-multivalue-probe.test.ts`
(docblock, the "LAST member" pin, the "every dependent row" pin);
- `packages/objectql/src/engine-cascade-delete-user-field.test.ts`
(multi-valued `set_null`);
-
`packages/runtime/src/cascade-delete-multivalue-lookup-real-driver.integration.test.ts`
(real SQLite driver, read back from the database);
-
`packages/services/service-automation/src/builtin/delete-record-predicate-walk-batched.integration.test.ts`
(batched predicate walk).
- **Prose**: the "`[]`, never `null`" sentence is replaced for optional
columns in `packages/core/src/utils/required-emptiness.ts`, the two
`packages/objectql/src/engine.ts` blocks (`set_null` doc and the
member-removal comment) and the `record-validator.test.ts` docblock. The
`required` half (an empty list fails `required`) is kept everywhere.
- **Docs**: six hand-written pages now say an emptied optional
multi-value field is stored as `null`, that a row stored earlier can
still hold `[]`, and that `is_empty` matches both:
`content/docs/api/data-api.mdx`,
`content/docs/data-modeling/field-types.mdx`,
`content/docs/data-modeling/fields.mdx`,
`content/docs/data-modeling/validation-rules.mdx`,
`content/docs/deployment/troubleshooting.mdx`,
`content/docs/protocol/objectql/types.mdx`. No release notes, no
CHANGELOG, no `content/docs/references/`.
- **Readonly columns** (`b702c0f1`, round-2 ruling A, record
6598733139): the `'emptyList'` arm never reaches a `readonly: true`
column. A readonly column is platform-stamped and never cleared by a
writer, so `sys_activity.changed_fields` keeps `[]` for "stamped,
nothing changed" (ADR-0139 E6). `BlankAsNullFieldDef` gains `readonly?:
boolean`. The `'blank'` and `'empty'` arms are unchanged. One unit pin
covers it, with controls.
- **Changeset** `.changeset/29343-multi-value-blank-null.md`
(`@objectstack/objectql` and `@objectstack/core`, patch): names ruling
A, the Clause-② widening and the read change (`[]` → `null` for optional
multi-value, including the cascade repair).
## Why
Ruling A on this card (record 6591577985): an optional multi-value
column written `[]` or `''` stores `null`, unique-covered or not;
`required` still means a non-empty list; the `$empty` table is
unchanged. The spec half (`FieldSchema`'s `multiple` and `required` doc
blocks) landed in #29763 for #29652. Stored `'[]'` / `'""'` rows are
cleaned by #29653, not here.
## Tests
All readings below are at head `8e4ba024` (the three commits plus a
merge of `origin/main` `b62463f1`), except the red-first reading, which
is at `origin/main` `97c2982c`. Head `60dc73ff` adds one comment-only
commit: the `blankSingleValueColumns` docblock in
`packages/cli/src/commands/migrate/blank-single-values.ts` (the contract
review's should-fix, 6597445147). At `60dc73ff`, the cli unit test for
the command passes 46/46, its integration test passes 6/6, and the cli
typecheck exits 0. Head `b702c0f1` (after merging `origin/main`
`55dca466` as `b784ff6a`) adds the readonly carve-out. At `b702c0f1` the
following pass:
- cli `activity-changed-fields.integration` 4/4 and
`blank-single-values` 6/6;
- plugin-audit `activity-changed-fields-backfill.integration` 5/5;
- REST 34/34;
- core 2937/2937 and its typecheck;
- objectql 10777/10777;
- service-automation 4/4;
- the runtime real-driver suite (7, 1 skipped).
With the carve-out removed, the new pin and exactly the CI reds go red
(cli cases 2–4, plugin-audit case 2). They pass again once it is
restored.
- Red first, at `origin/main` `97c2982c` with its own build:
`rest-data-multi-value-blank.test.ts` 12 failed / 3 passed (15). Every
per-type unique case (multiselect, multi-value lookup, tags,
unique-index member) answered 409 on the second `[]` or `''`.
- Green after the change: that file plus `rest-data-single-value-blank`
and `rest-data-optional-unique-blank` 34/34; objectql targeted files
191/191; runtime real-driver file 7 passed, 1 skipped (the Postgres
cell, needs `OS_TEST_POSTGRES_URL`); service-automation batched walk
4/4.
- Whole packages: `@objectstack/objectql` test 545 files, 10767/10767
(before and after merging `main`); `@objectstack/core` test 132 files,
2936/2936; metadata-protocol `seed-loader` files 26, 257/257; cli
`blank-single-values.integration.test.ts` 6/6 (integration project).
Typecheck exit 0 for core, objectql, runtime, service-automation.
- Ablation, one-shot: `blankStorageForm`'s `'emptyList'` arm replaced to
return the value unchanged (`scripts/ablation-replace.mjs`), core
rebuilt, `ablation-dist-preflight.mjs` exit 0. Result: objectql 5 failed
/ 38 passed (both probe pins, the user-field pin, two door unit pins);
REST 12 failed / 3 passed. Restored: blob equals HEAD, `git diff HEAD`
empty, rebuilt, preflight `--absent` exit 0, the same files 43/43 and
15/15. A first attempt carried its marker in a comment, which the build
strips; its preflight answered exit 1, so that run is void and was
repeated with a code marker.
## Acceptance notes
- Read change, stated in the changeset: an optional multi-value field
cleared from now on reads `null`, not `[]`, including the `set_null`
cascade repair that removes the last member. `$empty: true` matches
both. A reader that branches on `Array.isArray` alone has to read `null`
as empty too.
- `packages/cli/src/commands/migrate/blank-single-values.ts` (docblock
near line 109) still says the `blankAsNullColumns` map holds three arms;
it now holds four. That file is outside this claim's surface; the
behaviour is pinned (`blank-single-values.integration.test.ts` keeps its
column set). Noted for the seat.
- `test:repo` (vitest `repo` project) was not run locally; it belongs to
CI (`Test Core`).
## Verification
- `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack
--commands` at `8e4ba024`: 101 families derived. All 101 run locally;
every one exit 0. At `60dc73ff`, the derivation is the same 101
families. The 46 families on the changed path re-ran, all exit 0, and
`--ran` reports 101 run with 0 NOT-MEASURED. At `b702c0f1`: 101 derived,
101 run, all exit 0, and `--ran` exits 0.
- Seven first answered exit 3 (`PREREQUISITE NOT MET`, built output
missing: `check:skill-examples`, `check:dts-closure` (exit 1, read while
a sibling build rewrote `driver-sql/dist`),
`check:dual-build-cjs-loads`, `check:i18n`, `check:i18n-coverage`,
`check:i18n-walk-parity`) or could not reach a pinned fixture commit on
the shallow clone (`check-plugin-teardown-shape.mjs --self-test`). After
building the client SDK and the packages closure, and fetching that
commit, each re-ran exit 0.
- `dispatch-gates --ran`: 101 derived, 101 run, 0 NOT-MEASURED, 0 UNRUN,
exit 0.
- CI runs the full farm (`pnpm lint`, `test:repo`, dogfood is not owed:
no `!` in the title and no `(narrowing)` arm).
## Human Minimum
- [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic.
(No `packages/spec` change.)
- [x] 2. Every name I added uses the casing for its kind
(`'emptyList'`).
- [x] 3. No formatter ran; `pnpm lint` belongs to CI.
- [x] 4. One issue, one branch off an up-to-date `main`, one pull
request.
- [x] 5. The changeset carries both declaration lines, as does this
body.
- [x] 6. `packages/spec` not touched.
- [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.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
https://claude.ai/code/session_01JQwxbWuRcoCQaZSTv51YHt
---
_Generated by [Claude
Code](https://claude.ai/code/session_01JQwxbWuRcoCQaZSTv51YHt)_
<!-- signed: domain:server#3 · os-warren ·
session_01JQwxbYyp6wSRcywGFK2L4A -->
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent 74fd4bf commit 8209a2f
22 files changed
Lines changed: 585 additions & 95 deletions
File tree
- .changeset
- content/docs
- api
- data-modeling
- deployment
- protocol/objectql
- packages
- cli/src/commands/migrate
- core/src/utils
- objectql/src
- validation
- rest/src
- runtime/src
- services/service-automation/src/builtin
| 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 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
301 | 301 | | |
302 | 302 | | |
303 | 303 | | |
304 | | - | |
305 | | - | |
306 | | - | |
307 | | - | |
| 304 | + | |
| 305 | + | |
| 306 | + | |
| 307 | + | |
| 308 | + | |
308 | 309 | | |
309 | 310 | | |
310 | 311 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
496 | 496 | | |
497 | 497 | | |
498 | 498 | | |
499 | | - | |
| 499 | + | |
500 | 500 | | |
501 | 501 | | |
502 | 502 | | |
| |||
1022 | 1022 | | |
1023 | 1023 | | |
1024 | 1024 | | |
1025 | | - | |
| 1025 | + | |
1026 | 1026 | | |
1027 | 1027 | | |
1028 | 1028 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
292 | 292 | | |
293 | 293 | | |
294 | 294 | | |
295 | | - | |
296 | | - | |
297 | | - | |
| 295 | + | |
| 296 | + | |
| 297 | + | |
298 | 298 | | |
299 | 299 | | |
300 | 300 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
261 | 261 | | |
262 | 262 | | |
263 | 263 | | |
264 | | - | |
265 | | - | |
266 | | - | |
267 | | - | |
268 | | - | |
269 | | - | |
270 | | - | |
| 264 | + | |
| 265 | + | |
| 266 | + | |
| 267 | + | |
| 268 | + | |
| 269 | + | |
| 270 | + | |
| 271 | + | |
271 | 272 | | |
272 | 273 | | |
273 | 274 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
250 | 250 | | |
251 | 251 | | |
252 | 252 | | |
253 | | - | |
254 | | - | |
255 | | - | |
256 | | - | |
257 | | - | |
| 253 | + | |
| 254 | + | |
| 255 | + | |
| 256 | + | |
| 257 | + | |
| 258 | + | |
| 259 | + | |
258 | 260 | | |
259 | 261 | | |
260 | 262 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
726 | 726 | | |
727 | 727 | | |
728 | 728 | | |
729 | | - | |
730 | | - | |
731 | | - | |
732 | | - | |
733 | | - | |
734 | | - | |
| 729 | + | |
| 730 | + | |
| 731 | + | |
| 732 | + | |
| 733 | + | |
| 734 | + | |
| 735 | + | |
| 736 | + | |
735 | 737 | | |
736 | 738 | | |
737 | 739 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
21 | 21 | | |
22 | 22 | | |
23 | 23 | | |
| 24 | + | |
24 | 25 | | |
25 | 26 | | |
26 | 27 | | |
| |||
100 | 101 | | |
101 | 102 | | |
102 | 103 | | |
| 104 | + | |
| 105 | + | |
| 106 | + | |
| 107 | + | |
| 108 | + | |
103 | 109 | | |
104 | 110 | | |
105 | 111 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
106 | 106 | | |
107 | 107 | | |
108 | 108 | | |
109 | | - | |
| 109 | + | |
110 | 110 | | |
111 | 111 | | |
112 | 112 | | |
113 | 113 | | |
114 | 114 | | |
115 | 115 | | |
116 | | - | |
117 | | - | |
118 | | - | |
| 116 | + | |
| 117 | + | |
| 118 | + | |
| 119 | + | |
| 120 | + | |
119 | 121 | | |
120 | 122 | | |
121 | 123 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
24 | 24 | | |
25 | 25 | | |
26 | 26 | | |
27 | | - | |
| 27 | + | |
28 | 28 | | |
29 | 29 | | |
30 | 30 | | |
| |||
44 | 44 | | |
45 | 45 | | |
46 | 46 | | |
47 | | - | |
| 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 | + | |
48 | 86 | | |
49 | 87 | | |
50 | 88 | | |
| |||
66 | 104 | | |
67 | 105 | | |
68 | 106 | | |
| 107 | + | |
| 108 | + | |
| 109 | + | |
| 110 | + | |
| 111 | + | |
| 112 | + | |
| 113 | + | |
| 114 | + | |
| 115 | + | |
| 116 | + | |
| 117 | + | |
69 | 118 | | |
70 | 119 | | |
71 | 120 | | |
| |||
0 commit comments