Skip to content

fix(objectql): a cleared optional unique text field stores null, so the second record cleared is no longer refused 409 UNIQUE_VIOLATION (#29176) - #29271

Merged
objectstack-fleet[bot] merged 5 commits into
mainfrom
claude/project-thread-gbekk6
Nov 2, 2026
Merged

objectstack-fleet[bot] merged 5 commits into
mainfrom
claude/project-thread-gbekk6

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Requested by Steve · project thread
Fixes #29176

Clause-②: no
Metadata-protocol: none

维护者速读

  • 改了什么:可选的唯一文本字段(SKU、税号、工号这类)被清空时,服务端写入口把 '' 存成 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

  • The seam. normalizeBlankTypedValues (packages/objectql/src/validation/record-validator.ts) is the engine's existing blank write door: ObjectQL.insert() / update() and the dry run validate() 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.ts is not touched.
  • The new arm. Exactly '' becomes null on a column that is all of:
    • text-like: the spec's STRING_VALUE_TYPES, read as the constant, minus password and secret;
    • not required: true;
    • covered by a unique constraint, on either surface the drivers build one from: the field's own unique, or membership in a declared indexes[] entry carrying unique. Both are read through the spec's isUniqueDeclared, the predicate the drivers use.
    • Lifecycle columns the engine owns (id, created_at, …) are never rewritten.
  • Edges, each deliberate.
    • Whitespace stays a value. The $empty operator's ruled table reads '' and null as one answer on a text-like column and whitespace as a value, so this rewrite changes no $empty / is_empty answer.
    • required columns are excluded, so a required unique field's '' is refused required exactly as today. Without the exclusion, a required field with a defaultValue would take the default instead of being refused.
    • password / secret are excluded: a maintainer ruling refuses '' on them while null means clear, and rewriting would turn that refusal into a silent clear.
    • select / radio / lookup / user are excluded: the same $empty table reads their '' as a value. See Acceptance notes.
  • Defaults. An optional unique field with a defaultValue takes it on insert when sent '', exactly as when sent null (the typed arm already behaves this way).
  • FieldDef in the validator gains unique?: unknown; the function's parameter type gains indexes?: 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, the str_empty round-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; the defaultValue interaction; required unchanged on insert, update and the dry run, including the required-with-default case; '' on a unique password still refused (EmptyCredentialWriteError, VALIDATION_ERROR / 400).
  • packages/rest/src/rest-data-optional-unique-blank.test.ts (8 tests), real ObjectQL + SQLite SqlDriver + RestServer routes, physical column read past the engine: POST '' twice, PATCH clear twice, a declared composite unique index member, batch create and createMany, a non-unique control keeping '', a real duplicate still 409 UNIQUE_VIOLATION (by POST and by PATCH), a required unique field 400 VALIDATION_FAILED / required with and without a defaultValue, and a legacy stored '' row.
Red on main, green on head

record-validator.ts restored to dedc3505 in the tree only, objectql rebuilt (optionalUniqueTextColumns absent from all 14 built files), then restored to HEAD (blob b450ff13 equal to HEAD, git diff HEAD empty) and rebuilt.

Reading objectql (2 new files) rest (new file)
Base dedc3505 Tests 9 failed, 8 passed (17) Tests 6 failed, 2 passed (8): POST [201, 409], PATCH [200, 409], declared index [201, 409], batch [true, false]
Head 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 by scripts/ablation-dist-preflight.mjs (present in 4 built files) before the run. Each was restored to the HEAD blob with git diff HEAD empty. A final rebuild shows all three markers absent and the tree clean.

Ablation Red
A1: the door also rewrites a non-blank value on a covered column rest a real duplicate is still refused: expected 201 to be 409; objectql whitespace and every non-blank value pass through untouched
A2: the required exclusion removed objectql … with a defaultValue refuses "" — it is NOT defaulted and the unit required control; rest a required unique field refuses "": expected 201 to be 400
A3: the credential exclusion removed objectql the credential ruling is unchanged: expected undefined to be an instance of EmptyCredentialWriteError, and the unit credentials control

Acceptance notes

  • Driver families, measured on dedc3505 (two rows in a unique text column, through each driver's own create): driver-sql on SQLite: two '' → second refused, two null → both land. driver-memory: the same. sqlite-wasm and turso local share SqlDriver's compiler and DDL path, so they were not measured separately. The engine now hands every driver null; driver-memory already pins NULL-distinct in memory-unique-constraint.test.ts.
  • MongoDB, NOT MEASURED (the mongod download for mongodb-memory-server is refused with 403 here). By reading: mongodb-schema.ts builds field uniqueness as { unique: true, sparse: true }. A sparse index skips a document only when the field is absent, and still indexes an explicit null. If that holds, two cleared rows still collide on that driver, because the engine now sends null. 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.
  • Not covered, by scope. A unique single-value select / lookup that is cleared also sends '' (the card's own reading on showcase_field_zoo) and still collides on the second record. The $empty table reads '' as a value on those types, so rewriting it is a separate decision. Reported to the seat.
  • Stored rows. Existing '' values are not migrated (pinned): they keep matching field = ''; a newly cleared row matches $null: true; $empty: true matches 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
Run Head Result
pnpm --filter @objectstack/objectql test 9ef4c2cf Test Files 529 passed (529) · Tests 10155 passed (10155)
pnpm --filter @objectstack/objectql typecheck 9ef4c2cf exit 0; check:test-typecheck held (new files carry no debt)
pnpm --filter @objectstack/rest test 9ef4c2cf Test Files 368 passed (368) · Tests 6971 passed, 332 skipped (7303)
pnpm --filter @objectstack/rest typecheck 9ef4c2cf exit 0; 0 test-typecheck debt
git merge origin/main (49aba82b), then pnpm install --frozen-lockfile, rebuild of the rest closure, spec check:generated 8c537232 all exit 0; every generated artifact up to date. The incoming commits touch neither objectql nor rest, so the scoped re-check is the three new files: 17/17 and 8/8
the one later commit (test helpers renamed, see below) 28fb6173 the edited file 8/8
dispatch-gates.mjs --commands (61 families), each run 28fb6173 61 × exit 0; --ran: 61 derived, 61 run, 0 NOT-MEASURED, 0 UNRUN
pnpm lint 28fb6173 exit 0
check:driver-conformance before (49aba82b) and after 28fb6173 66 covered, 0 DEBT, both

The first gate round (8c537232) had one real red: check:where-matcher discovered the two expectation helpers in engine-optional-unique-blank-door.test.ts as 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-test needed its pinned fixture commit fetched, and check:dual-build-cjs-loads needed every package built. Both were fixed before round two.

Not run locally: the full dogfood battery. The title carries no ! and Clause-② has no (narrowing) arm, so it is not owed.

Human Minimum

  • 1. Schemas start as Zod; packages/spec holds no runtime logic. (not touched)
  • 2. Every name I added uses the casing for its kind.
  • 3. No formatter ran; pnpm lint passes.
  • 4. One issue, one branch off an up-to-date main, one pull request.
  • 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset.
  • 6. packages/spec touched: n/a.
  • 7. No hand edits to content/docs/references/, packages/*/CHANGELOG.md or content/docs/releases/.
  • 8. Title, body and comments are in English (the maintainer brief above is Chinese by repo rule).

Generated by Claude Code

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>
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>
@github-actions github-actions Bot added size/l documentation Improvements or additions to documentation tests tooling labels Nov 2, 2026
@github-actions

github-actions Bot commented Nov 2, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

5 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
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 60 of 216 client-bound route-ledger rows — the other 156 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 156: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 101 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 20 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 74a19dd857d5b7bddd8409bd04332cc10c6ad5ae → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 884c6ba0045b7ecd10dccc18cbbd6b08588a1e83 — the merge of head 28fb617301450ca06665a8bd4a901a10c0722bc9 into base 74a19dd857d5b7bddd8409bd04332cc10c6ad5ae, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#3 · os-steve · session_01ADHkmQJdgarUjPLNgFBY67

席位核对 · ACCEPT · PR #29271 · 2026-11-02T10:53Z

Head-sha: 28fb617301450ca06665a8bd4a901a10c0722bc9

维护者速读(席位终稿)

核对六项

  1. 形态:draft → main;Fixes #29176(第 3 行,前两行是线程署名块),与认领一致;5 个文件(changeset、record-validator、三份新测试),均在申报面内;未改任何既有测试。
  2. 前提实测:席位读 diff:落点是写入门已有的 normalizeBlankTypedValues(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 那道门),insert / insertMany / 按 id 与按条件 update / 试运行都先过它,所以 engine.ts 不用动,与 fix(objectql): an update carrying a stored reference back unchanged is not refused because it stopped resolving (#29062) #29245 无交叠。覆盖判定用 spec 的 isUniqueDeclared(驱动建约束用的同一谓词),字段自身 unique 与 indexes[] 唯一项两面都读;类型取 spec STRING_VALUE_TYPES 去掉 password / secret,不另列。dev 红先:主线 POST [201,409]、PATCH [200,409];三处负钉(真重复 409、必填 ''、密码 '')各消融转红并复原。
  3. 门禁:dev 本地 dispatch-gates 61/61 exit 0(--ran 0 NOT-MEASURED),pnpm lint 0,objectql 529 文件、rest 368 文件绿,typecheck 绿;驱动账本 66 覆盖 / 0 DEBT,前后不变。driver-mongodb 未测(容器拉不到 mongod),见第 5 项。CI 在跑,入队前席位读本 head 全部 check-runs。
  4. 接受的偏差:三个 open_question 均取 A:(1)只改受唯一约束覆盖的列,非唯一文本仍存 ''(spec 往返夹具 str_empty 仍成立;改所有列另立卡);(2)只认正好 '',空白仍是值,与 $empty 裁定表一致,不改任何 is_empty 答案;(3)带 defaultValue 的可选唯一字段收到 '' 时取默认值,与同一道门的类型化分支一致,已钉住。PR 正文首行是线程署名块,属已知例外。
  5. 范围外去向:dev 报两条,席位已立卡:objectql: a cleared optional unique lookup or select still stores '' and the second cleared record is refused 409 UNIQUE_VIOLATION #29277(唯一的可选 lookup / select 清空后仍存 '',第二条 409;需先裁定 $empty 读法);driver-mongodb: field unique is built as a sparse index, so two explicit null values collide, unlike the NULL-distinct rule of driver-sql and driver-memory #29278(driver-mongodb 唯一索引为 sparse,两个显式 null 会冲突,与 sql / memory 不一致;未实测)。Metadata-protocol: none。
  6. 落地条款:Clause-②: no — 今天被拒的写入现在通过,同时 '' 的存储形态在这类列上变为 null;未找到写明 '' 原样存储的已发布契约(str_empty 夹具针对非唯一列,未受影响),无 packages/spec/src/**,标题无 !,免契约复审与 dogfood。semver:@objectstack/objectql patch,与 changeset 一致。check-governed-merges --pr 29271:NOT governed。落地授权,维护者原话:「按照 pm-dispatch 规则复核过的都授权」(2026-10-25T11:35Z)。

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review November 2, 2026 11:12
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Nov 2, 2026
Merged via the queue into main with commit 9e6df40 Nov 2, 2026
49 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/project-thread-gbekk6 branch November 2, 2026 12:01
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/l tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

data: clearing an optional unique text field stores an empty string, so the second record whose field is cleared is refused 409 UNIQUE_VIOLATION

1 participant