Repository navigation
fix(objectql): a cleared optional single-value select or lookup stores null, so a second unique clear is no longer refused 409 (#29277) - #29464
Conversation
…ect/lookup (#29277) Claude-Session: https://claude.ai/code/session_01ADHkmRPC4f86r9bWGuYWJu Co-authored-by: Claude <noreply@anthropic.com>
…s null (#29277) Claude-Session: https://claude.ai/code/session_01ADHkmRPC4f86r9bWGuYWJu Co-authored-by: Claude <noreply@anthropic.com>
…rm (#29277) Claude-Session: https://claude.ai/code/session_01ADHkmRPC4f86r9bWGuYWJu Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 3 package(s): 23 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 9 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 51 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin ea18058ba0432ef0b6d927d0745d84631a3de680 && git checkout ea18058ba0432ef0b6d927d0745d84631a3de680
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 399df5bba888ff7cf7531d01d8dcb61d7690438a 22a779351bf40b62e478721343b78d8360b53b7d && git checkout -B drift-repro 399df5bba888ff7cf7531d01d8dcb61d7690438a && git merge --no-ff 22a779351bf40b62e478721343b78d8360b53b7d
node scripts/docs-audit/affected-docs.mjs --json 399df5bba888ff7cf7531d01d8dcb61d7690438a
|
… row plus a stated one-string set; file references join (#29277) Claude-Session: https://claude.ai/code/session_01ADHkmRPC4f86r9bWGuYWJu Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ADHkmRPC4f86r9bWGuYWJu Co-authored-by: Claude <noreply@anthropic.com>
…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>
…ames moved to core (#29277) The four VOCABULARY rows for record-validator's SKIP_FIELDS literal go: the names now come from @objectstack/core's ENGINE_LIFECYCLE_COLUMNS. Claude-Session: https://claude.ai/code/session_01ADHkmRPC4f86r9bWGuYWJu Co-authored-by: Claude <noreply@anthropic.com>
|
domain:server#3 · os-steve · session_01ADHkmQJdgarUjPLNgFBY67 Contract-review-claim: session_01ADHkmQJdgarUjPLNgFBY67 · head 22a7793 · 2026-11-02T17:44Z |
|
domain:server#3 · os-steve · session_01ADHkmQJdgarUjPLNgFBY67 Contract reviewServed-tier: Read: card #29277 (body and all 8 comments: triage 6575406743, claim 6576911211, dev reports 6577038603 and 6581571774, block 6577287839, seat rulings 6581612131 and 6582252856, claim amendment 6584075217), maintainer ruling B on #29346 (6579813296), the PR body, its 17-file list, and the net diff Check-runs on the head: green includes Lint and Repo Gates, Lint (ESLint, PM dispatch-gates, docs and code guards, repo and tooling gates, CI/release/engine gates, the scoped lints), Type Check (debt ledger, source gates), PR-body guards, Governed Surface Queue Guard, Dogfood Regression Gate, Check PR Size, Plan Test Core shards. Red: ① Derived judgments
② Semver levelClause-②: yes (widening)
③ Boundary flags
Implemented-by: VERDICT: PASS |
|
domain:server#3 · os-steve · session_01ADHkmQJdgarUjPLNgFBY67 席位核对 · ACCEPT · PR #29464 · 2026-11-02T17:52ZHead-sha: 维护者速读(席位终稿)
核对六项
|
⛔ merge queue 构建失败 — 先分诊,再决定要不要重排队列构建 41498966913 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集), 分类: 失败的 job(日志抽取,best effort):
跨 PR 相同签名(24h,按失败测试文件聚合):
历史信号:
分诊清单:
Generated by Claude Code · merge-queue-triage workflow (#4859) |
Claude-Session: https://claude.ai/code/session_01ADHkmPFEXGDQbc7fNcM24e Co-authored-by: Claude <noreply@anthropic.com>
domain:server#3 · os-steve · session_01ADHkmQJdgarUjPLNgFBY67
Requested by Steve · project thread
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的规则(三条:record write door:''skips every type check, so a number, boolean, date, datetime or time column stores an empty string — normalise it to null at the door (seam from objectui#10813) #20308 类型列、data: clearing an optional unique text field stores an empty string, so the second record whose field is cleared is refused 409 UNIQUE_VIOLATION #29176 唯一文本、本卡单值列)移到@objectstack/core一处(blankAsNullColumns/blankStorageForm),写入口和种子加载器的重放比较都读它。种子里写''的行重放时不再每次被当作变更重写(主干上三条都会这样空转,已实测)。core 新增导出,所以声明改为Clause-②: yes (widening)。''既不是选项值,也不是记录 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
''asnullon every optional column whose type sits on the$emptytable'snull_onlyrow and is string-stored single-value. The$emptytable is unchanged;requiredkeeps refusing''; a blank on insert takes a declareddefaultValue; cleaning stored''is a separate migrate-lane card.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 runvalidate()apply it before anything else reads the payload, and every REST, batch, upsert and import door reaches the engine through those methods.engine.tsis not touched.@objectstack/core(packages/core/src/utils/blank-storage-form.ts):blankAsNullColumns(objectSchema)maps each column to its reach,'blank'(anyisMissingblank: the record write door:''skips every type check, so a number, boolean, date, datetime or time column stores an empty string — normalise it to null at the door (seam from objectui#10813) #20308 typed arm,NON_TEXT_STORED_VALUE_TYPES, unchanged) or'empty'(exactly''), andblankStorageForm(value, reach)gives the stored form. An'empty'column is notrequired: true, not a lifecycle column the engine owns (ENGINE_LIFECYCLE_COLUMNS, which the validator'sSKIP_FIELDSnow reads), and EITHER text-like and unique-covered (data: clearing an optional unique text field stores an empty string, so the second record whose field is cleared is refused 409 UNIQUE_VIOLATION #29176, unchanged) OR on the$emptytable'snull_onlyrow (expandEmptyOperator(def).arm, read first, so amultiple: truecolumn, themulti_valuerow of objectql: a multi-value unique column (multiselect, or lookup with multiple: true) cleared to [] on two records refuses the second with 409 UNIQUE_VIOLATION #29343, is out) AND storing one string (ONE_STRING_STORED_TYPES).normalizeBlankTypedValuesconsumes it and keeps no arm logic of its own.isNoOpReplay(packages/metadata-protocol/src/seed-loader.ts) compared a seed''literally with the storednull, so every seed row writing''on a blank-as-null column read as changed and was rewritten on each replay. A new replay comparandReplayComparands.blankAsNull(built once per dataset from the definition byblankAsNullColumns, besidetemporalandmasked) puts the SEED side in its stored form beforeseedValueEquals. There is no blanket'' == null: on a column the door keeps''(plain text), a seed''against a storednullis still a change (pinned). Pre-existing on main: measured on head071872b7(where the record write door:''skips every type check, so a number, boolean, date, datetime or time column stores an empty string — normalise it to null at the door (seam from objectui#10813) #20308 and data: clearing an optional unique text field stores an empty string, so the second record whose field is cleared is refused 409 UNIQUE_VIOLATION #29176 arm code and the seed loader are byte-identical toorigin/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.ONE_STRING_STORED_TYPESis 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 opaquesys_fileid). A pin ties it to the table: everyFieldTypewhose expansion isnull_onlyand that the typed arm does not own must sit in exactly one of that set,STRUCTURED_JSON_TYPESorCOMPUTED_VALUE_TYPES, so a type that joins the row unclassified turns the pin red.''twice, on a unique column):json,composite,record,location,address,repeater,vectorstore an object or an array ({"a":1},[0.1,0.2]), so''is not a blank of their shape;formulais never stored;autonumberis issued by the engine on insert whatever the caller sent (A-1,A-2,A-3for a value,'',''). The file references stored one string id (JSON-encoded on SQL:"img_1") and a second''was refusedDUPLICATE_RECORD, so they join.'': 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 readsowner_id''andnullas one (security-plugin-authz-middleware.ts, therow.owner_id == null || row.owner_id === ''checks), so its answer does not move.@objectstack/coreexportsblankAsNullColumns,blankStorageForm,ENGINE_LIFECYCLE_COLUMNSand the typesBlankAsNullReach/BlankAsNullFieldDef. The changeset adds@objectstack/coreand@objectstack/metadata-protocolpatch entries. No spec edit.BOUNDED_STRING_FIELD_TYPESstring arm (records: a number or a boolean written to a text-family field is admitted on its string reading but stored as the raw number/boolean, so atextcolumn hands back42, not"42"#29241) nor the select value-shape check (records: a free-formselectfield (nooptions) admits a plain object or an array on create and stores it as-is, although a select's stored value is a scalar option value #29244) is touched..changeset/29176-optional-unique-blank-null.md(unreleased) saidselect/lookupcolumns 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, socheck-empty-changesetreports it (see Verification).Why
''is neither an option code nor a record id. Stored as sent, it was a value to SQLUNIQUE(the second cleared record on a unique column was refused 409) and not empty to$empty, whose ruled table reads onlynullas empty on these types. The formula matcher,havingand CELisBlankalready 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 fromFieldTypeand the table, with every untypednull_onlytype classified into exactly one storage class; the joined set (11 types) and the left-out set (9 types) pinned; the left-out types keep'';''becomesnullon every optional member, unique or not;required(with and without adefaultValue) keeps'';multiple: trueis themulti_valuerow 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 lifecyclecreated_at; unique text by field and by index, plain text andpasswordout; one-stringselect/lookup/imagein, required /multiple: true/json/autonumber/created_byout); the lifecycle list; an unresolvable definition declares none;blankStorageFormper reach.packages/runtime/src/seed-blank-as-null-replay.integration.test.ts(2 tests), realObjectQL+ SQLiteSqlDriver+SeedLoaderService: one seed''per arm (number, date, unique text, select) landsnulland a replay skips all four (totalUpdated: 0,totalSkipped: 4); control: a plain text column storednulland reseeded''is still updated and stores''.packages/rest/src/rest-data-single-value-blank.test.ts(11 tests), realObjectQL+ SQLiteSqlDriver+RestServerroutes, physical column read past the engine: POST''on all eleven joined types stores NULL; PATCH clear on all eleven; uniqueselect+lookupcreated''twice and cleared by PATCH twice both land; a uniqueimagecreated''twice both land; a declared unique-index member; batch create andcreateMany; a real duplicate still409 UNIQUE_VIOLATION; requiredselect/lookupstill400 VALIDATION_FAILED/required(one with adefaultValue); a blank on insert takes the declareddefaultValueexactly asnulldoes; the$emptypin: on a row written''past the engine,$empty: trueanswers only the newly cleared row,$empty: falseanswers 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 nowautonumberonly.packages/objectql/src/engine-optional-unique-blank-door.test.ts: the uniquepick(select) /ref(lookup) columns move from the kept-''set to the covered-nullset.packages/objectql/src/engine-blank-typed-value-door.test.tsandpackages/rest/src/rest-data-blank-typed-value.test.ts: thec_lookup/c_selectcontrols now expectnull;c_textstill''.Round 3 forced edits (no expectation changed):
packages/runtime/src/seed-blank-reference-real-driver.integration.test.ts: the comment at theloan-emptyassertion said the insert door "does not null" a blank lookup; it now says an optional lookup's''is stored asnullsince objectql: a cleared optional uniquelookuporselectstill stores '' and the second cleared record is refused 409 UNIQUE_VIOLATION #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'sENGINE_LIFECYCLE_COLUMNS.Red on main, green on head
The two new files were committed first (
0acdf485) and run againstorigin/main96fb7b5d's objectql build: restTests 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: trueanswered[]), unitTests 2 failed | 5 passed (7). Two of those rest reds were my own fixture faults (atreevalue naming no record of its object, a whitespace lookup id the referential-integrity check refuses), fixed ina71af048; the whitespace case moved to the unit file. On head: rest 11/11, unit 8/8.Ablations
Made with
scripts/ablation-replace.mjs(anchor hit once), objectql rebuilt, marker proven in dist byscripts/ablation-dist-preflight.mjs("marker present in 4 built files"), each restored withgit 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).isSingleValueCodeColumnnever true)Tests 8 failed | 2 passed (10)(every positive case, including the$emptypin), unit 2 failedmultiple: truecolumn joins)a multiple: true column is the multi_value row, not this armFILE_REFERENCE_TYPESdropped from the one-string setTests 3 failed | 8 passed (11)(POST, PATCH, unique image), unit"" on every optional member becomes nullSTRUCTURED_JSON_TYPESadded to the one-string setthe left-out types keep ""isNoOpReplaycompares the raw seed value), metadata-protocol rebuiltTests 2 failed | 2 passed (4):seed-blank-as-null-replay(replaytotalUpdated4) andseed-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.
Acceptance notes
@objectstack/coregains 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 data: clearing an optional unique text field stores an empty string, so the second record whose field is cleared is refused 409 UNIQUE_VIOLATION #29176 declaredno.null, not'', and matches$empty: true,$null: trueand= 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.multiple: truefile or image column is themulti_valuerow and stays out (objectql: a multi-value unique column (multiselect, or lookup with multiple: true) cleared to [] on two records refuses the second with 409 UNIQUE_VIOLATION #29343).mongoddownload is 403 in this container).Verification
Head
22a77935(origin/main96fb7b5dmerged in ate607df78; a full-declaration rebuild of the runtime and rest closures). CI runs the rest: see the PR's check-runs.Suites, typecheck, lint, gates
5 passed (5)) · Tests 10320 · typecheck exit 0pnpm lintdispatch-gates --commands(63 derived), reconciled with--rancheck:type-check-debtfirst queue-timed out behind the suites, exit 3, then rerun under the lock: exit 0)node scripts/check-empty-changeset.mjs --base origin/main.changeset/29176-optional-unique-blank-null.md(see What changes). Not restored, noskip-changeset; the seat confirmed.Check Changesetis not a required context.check-changeset-no-majoragainst this body (offline--event)Clause-②: yes (widening)/Metadata-protocol: none, patchThe plugin-teardown self-test needed
git fetch origin 621a4876…andcheck:dual-build-cjs-loadsa full./packages/**build first; both then exit 0. Full dogfood not run: no!in the title and no(narrowing)arm.Generated by Claude Code