Repository navigation
fix(objectql): a cleared optional unique text field stores null, so the second record cleared is no longer refused 409 UNIQUE_VIOLATION (#29176) - #29271
Conversation
A cleared optional unique text field stores '' and the second record cleared is refused 409 UNIQUE_VIOLATION. Pins what the driver receives on every engine door, and the physical column / status on SQLite through REST. Claude-Session: https://claude.ai/code/session_01ADHkmPtnUk1SkRHh5gtX6u Co-authored-by: Claude <noreply@anthropic.com>
The write door (normalizeBlankTypedValues, run by insert, update and the dry run before anything reads the payload) now also rewrites exactly '' to null on a column that is text-like (the spec's STRING_VALUE_TYPES minus password/secret), not required, and covered by a unique constraint (its own unique, or a declared unique indexes[] entry). Every driver's unique constraint is NULL-distinct, so a cleared value no longer collides. Claude-Session: https://claude.ai/code/session_01ADHkmPtnUk1SkRHh5gtX6u Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ADHkmPtnUk1SkRHh5gtX6u Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ADHkmPtnUk1SkRHh5gtX6u Co-authored-by: Claude <noreply@anthropic.com>
check:where-matcher discovered the two expectation helpers, which iterated the keys of a payload object, as filter-capturing WHERE matchers it could not judge. Iterating a list of names, as the sibling blank-door test does, says the same thing and is not a matcher. Claude-Session: https://claude.ai/code/session_01ADHkmPtnUk1SkRHh5gtX6u Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift Check5 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run. What this run could not see
Coarse fallback — 20 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 884c6ba0045b7ecd10dccc18cbbd6b08588a1e83 && git checkout 884c6ba0045b7ecd10dccc18cbbd6b08588a1e83
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 74a19dd857d5b7bddd8409bd04332cc10c6ad5ae 28fb617301450ca06665a8bd4a901a10c0722bc9 && git checkout -B drift-repro 74a19dd857d5b7bddd8409bd04332cc10c6ad5ae && git merge --no-ff 28fb617301450ca06665a8bd4a901a10c0722bc9
node scripts/docs-audit/affected-docs.mjs --json 74a19dd857d5b7bddd8409bd04332cc10c6ad5ae |
|
domain:server#3 · os-steve · session_01ADHkmQJdgarUjPLNgFBY67 席位核对 · ACCEPT · PR #29271 · 2026-11-02T10:53ZHead-sha: 维护者速读(席位终稿)
核对六项
|
Requested by Steve · project thread
Fixes #29176
Clause-②: no
Metadata-protocol: none
维护者速读
''存成null。第二条清空的记录不再被409 UNIQUE_VIOLATION拒绝;REST、批量、导入都走这同一个入口。''当成一个真实值,而null互不冲突。用户清空第二条记录就保存不了,只能填假值污染数据。按分诊裁决修在服务端写路径(packages/objectql),不改表单。''的值;必填字段照旧拒绝,真重复照旧 409,password/secret的空串拒绝不变,非唯一字段仍存''。已存的''不迁移。回滚 = revert 本 PR。packages/objectql/src/validation/record-validator.ts的optionalUniqueTextColumns与normalizeBlankTypedRow改动的那一行;packages/rest/src/rest-data-optional-unique-blank.test.ts;.changeset/29176-optional-unique-blank-null.md。pnpm --filter @objectstack/rest exec vitest run src/rest-data-optional-unique-blank.test.ts,预期 8 个测试全绿(主干上同一文件 6 红:第二次清空是 409)。What changes
normalizeBlankTypedValues(packages/objectql/src/validation/record-validator.ts) is the engine's existing blank write door:ObjectQL.insert()/update()and the dry runvalidate()apply it to the caller's payload before the middleware, the caller snapshots, the defaults, the hooks and validation read it (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 put it there for non-string-typed columns). Every REST, batch, upsert and import door reaches the engine through those methods.engine.tsis not touched.''becomesnullon a column that is all of:STRING_VALUE_TYPES, read as the constant, minuspasswordandsecret;required: true;unique, or membership in a declaredindexes[]entry carryingunique. Both are read through the spec'sisUniqueDeclared, the predicate the drivers use.id,created_at, …) are never rewritten.$emptyoperator's ruled table reads''andnullas one answer on a text-like column and whitespace as a value, so this rewrite changes no$empty/is_emptyanswer.requiredcolumns are excluded, so a required unique field's''is refusedrequiredexactly as today. Without the exclusion, a required field with adefaultValuewould take the default instead of being refused.password/secretare excluded: a maintainer ruling refuses''on them whilenullmeans clear, and rewriting would turn that refusal into a silent clear.select/radio/lookup/userare excluded: the same$emptytable reads their''as a value. See Acceptance notes.defaultValuetakes it on insert when sent'', exactly as when sentnull(the typed arm already behaves this way).FieldDefin the validator gainsunique?: unknown; the function's parameter type gainsindexes?: unknown. Both widen only.Why
Triage 6573215352 ruled the layer: "the fix lands on the server write path in packages/objectql: normalise an empty string to null for an optional field before the unique index sees it, which also covers REST and import callers." The scope is the fields a unique constraint covers. A non-unique optional text field keeps storing
'': the validator's docblock, thestr_emptyround-trip case and two existing pins (engine-blank-typed-value-door.test.ts,rest-data-blank-typed-value.test.ts) depend on that, and none of them needed an edit.Tests
No existing test was edited. Three new files:
packages/objectql/src/validation/record-validator.optional-unique-blank.test.ts(9 tests): the population pinned to the spec set (12 types), all three unique scopes, the declared-index surface (a plain index member keeps''), the controls (no unique,unique: false, required, credentials, other string-stored types, a lifecycle column), whitespace and non-blank pass-through, purity.packages/objectql/src/engine-optional-unique-blank-door.test.ts(8 tests): what the driver receives on insert, insert array,insertMany, update by id, update by predicate; whitespace and non-blank untouched; thedefaultValueinteraction;requiredunchanged on insert, update and the dry run, including the required-with-default case;''on a uniquepasswordstill refused (EmptyCredentialWriteError,VALIDATION_ERROR/ 400).packages/rest/src/rest-data-optional-unique-blank.test.ts(8 tests), realObjectQL+ SQLiteSqlDriver+RestServerroutes, physical column read past the engine: POST''twice, PATCH clear twice, a declared composite unique index member, batch create andcreateMany, a non-unique control keeping'', a real duplicate still409 UNIQUE_VIOLATION(by POST and by PATCH), a required unique field400 VALIDATION_FAILED/requiredwith and without adefaultValue, and a legacy stored''row.Red on main, green on head
record-validator.tsrestored todedc3505in the tree only, objectql rebuilt (optionalUniqueTextColumnsabsent from all 14 built files), then restored to HEAD (blobb450ff13equal to HEAD,git diff HEADempty) and rebuilt.dedc3505Tests 9 failed, 8 passed (17)Tests 6 failed, 2 passed (8): POST[201, 409], PATCH[200, 409], declared index[201, 409], batch[true, false]Tests 17 passed (17)Tests 8 passed (8)The controls that pass on base are the ones that must: required refusals, the credential refusal, the non-unique
''.Ablations of the three negative pins
Each mutation was made with
scripts/ablation-replace.mjs(anchor hit 1 → 0, blob changed), objectql rebuilt, and the marker counted in dist byscripts/ablation-dist-preflight.mjs(present in 4 built files) before the run. Each was restored to the HEAD blob withgit diff HEADempty. A final rebuild shows all three markers absent and the tree clean.a real duplicate is still refused:expected 201 to be 409; objectqlwhitespace and every non-blank value pass through untouchedrequiredexclusion removed… with a defaultValue refuses "" — it is NOT defaultedand the unitrequiredcontrol; resta required unique field refuses "":expected 201 to be 400the credential ruling is unchanged:expected undefined to be an instance of EmptyCredentialWriteError, and the unit credentials controlAcceptance notes
dedc3505(two rows in a unique text column, through each driver's owncreate): driver-sql on SQLite: two''→ second refused, twonull→ both land. driver-memory: the same. sqlite-wasm and turso local shareSqlDriver's compiler and DDL path, so they were not measured separately. The engine now hands every drivernull; driver-memory already pins NULL-distinct inmemory-unique-constraint.test.ts.mongoddownload formongodb-memory-serveris refused with 403 here). By reading:mongodb-schema.tsbuilds field uniqueness as{ unique: true, sparse: true }. A sparse index skips a document only when the field is absent, and still indexes an explicitnull. If that holds, two cleared rows still collide on that driver, because the engine now sendsnull. That would be a driver divergence from the NULL-distinct rule the other drivers keep, and it is outside this card's ruled layer. Reported to the seat.select/lookupthat is cleared also sends''(the card's own reading onshowcase_field_zoo) and still collides on the second record. The$emptytable reads''as a value on those types, so rewriting it is a separate decision. Reported to the seat.''values are not migrated (pinned): they keep matchingfield = ''; a newly cleared row matches$null: true;$empty: truematches both; a new clear no longer collides with a stored''.Verification
Head after
git merge origin/main: see the PR's check-runs. Local readings:Suites, typecheck, gates, lint
pnpm --filter @objectstack/objectql test9ef4c2cfpnpm --filter @objectstack/objectql typecheck9ef4c2cfcheck:test-typecheckheld (new files carry no debt)pnpm --filter @objectstack/rest test9ef4c2cfpnpm --filter @objectstack/rest typecheck9ef4c2cfgit merge origin/main(49aba82b), thenpnpm install --frozen-lockfile, rebuild of the rest closure,spec check:generated8c53723228fb6173dispatch-gates.mjs --commands(61 families), each run28fb6173--ran: 61 derived, 61 run, 0 NOT-MEASURED, 0 UNRUNpnpm lint28fb6173check:driver-conformancebefore (49aba82b) and after28fb6173The first gate round (
8c537232) had one real red:check:where-matcherdiscovered the two expectation helpers inengine-optional-unique-blank-door.test.tsas WHERE matchers it could not judge, because they iterated the keys of a payload object. They now iterate a list of names, as the sibling blank-door test does (28fb6173). In the same round, two families exited 3 (prerequisite not met, nothing measured):check-plugin-teardown-shape --self-testneeded its pinned fixture commit fetched, andcheck:dual-build-cjs-loadsneeded every package built. Both were fixed before round two.Not run locally: the full dogfood battery. The title carries no
!andClause-②has no(narrowing)arm, so it is not owed.Human Minimum
packages/specholds no runtime logic. (not touched)pnpm lintpasses.main, one pull request.packages/spectouched: n/a.content/docs/references/,packages/*/CHANGELOG.mdorcontent/docs/releases/.Generated by Claude Code