Repository navigation
Commit b391feb
<!-- claude-projects-attribution: {"github_login":"os-steve"} -->
_Requested by **Steve** · [project
thread](https://claude.ai/code/project/chan_01FdL1XeUyvVNxLreh3F4Qi9?thread=cmsg_01FdL1XeUyvVNxLreh3F4Qi9VCmqM8tUaCACEK5ub61zmo)_
Fixes #29562
Clause-②: yes (narrowing)
Metadata-protocol: none
Skills-docs: none. No file under `skills/**` or `content/docs/**`
teaches writing a number, a boolean or a non-plain object into an option
field; a `git grep` of both trees for option fields shows option codes
written as strings only.
Measured on `origin/main` @ `d3175d59` (base) and on this branch (head),
the engine and REST doors, SQLite and driver-memory (table under
Acceptance notes): a `unique` option field now refuses `3` after `"3"`
(and `true` after `"true"`), and a `Date`, `Map`, `Set` or class
instance on an option field is now refused. Those are the narrowing. On
SQLite, `true` after `1` on a unique option field used to be refused
(both stored `"1.0"`) and now lands: the widening half. The metadata
protocol is untouched (`none`): no key, value or metadata type an author
writes moves.
## 维护者速读
- **改了什么**:选项类字段(单选 select / radio,多选 multiselect / checkboxes / tags,及
`multiple: true` 的 select / radio)收到数字或布尔值时,改为存储校验器判定时用的字符串(`3` 存为
`"3"`,`true` 存为 `"true"`,多选逐个元素同理);`Date`、`Map`、类实例等非普通对象一律以
`invalid_type` 拒绝。
- **为什么改**:规范规定选项字段的值是字符串选项码,但写入时把原始数字直接交给驱动:内存驱动存成数字 `3`,SQLite 存成
`"3.0"`,与存 `"3"` 的行分裂成两个值,筛选、分组、唯一约束因驱动而异。这是 #29241 文本类字段修复的选项类孪生。
- **风险与回滚**:同一次写入的接受/拒绝结果不变;变化在于唯一选项字段上 `3` 与 `"3"`
现在冲突(409),非普通对象被拒,以及用数字 `3` 作筛选值不再命中新写入的行(筛选侧由 #29486 决定,本 PR
不动)。旧行不改写。回滚即 revert 本 PR。
- **先看**:① `packages/objectql/src/validation/record-validator.ts` 的
`normalizeNumericStringRow`(选项分支)与 `isObjectValue`;② 单选分支与多选元素分支的形状判定;③
`packages/rest/src/rest-data-option-scalar-value.test.ts`(真实 SQLite)。
- **两分钟验证**:在已构建的工作区跑 `pnpm --filter @objectstack/rest exec vitest run
src/rest-data-option-scalar-value.test.ts`,预期 10/10 通过:`POST { stage: 3
}` 的物理列为 `"3"`,唯一字段先 `3` 后 `"3"` 第二次为 409。
- **需要你做的**:无,席位落地
## What changes
- **The door writes the judged string** (`normalizeNumericStringRow` in
`packages/objectql/src/validation/record-validator.ts`, the function the
#29241 text-family half lives in, reusing its `isStringReadScalar` and
`String(value)`). On an option-family field (`SINGLE_OPTION_TYPES` ∪
`MULTI_OPTION_TYPES`, the spec's two sets, read and not re-listed), a
number or a boolean is written as `String(value)`. On a multi-value
option field, each number or boolean element is written the same way,
and a lone scalar is written as its string before the engine wraps it.
It runs where the text half runs: insert, insertMany, update by id,
update by predicate and the `validate()` dry run, `readonly` values
included (their shape is judged on every write). Not touched: strings,
`null`, objects (refused), an array on a single-value field (refused),
and non-scalar elements.
- **The option arms refuse any object** (`isObjectValue`). The
single-value select / radio arm and the multi-value element test used
`isPlainObjectValue || Array.isArray`, which is prototype-strict, so a
`Date`, `Map`, `Set` or class instance fell through to membership. With
no `options` it was stored as sent; with `options` it was refused
`invalid_option` by the accident of its string reading. Now each is
refused `invalid_type` with the #29244 value-shape sentence ("expected
one option value, received an object", or "… an element that is an
object"). It is never stringified, per the seat ruling 6587559938. The
text arm keeps its own posture; this rule is on option-family fields
only.
- **Header realigned** (should-fix S3 of review 6585290571): the "Rules
applied" list now follows `validateOne`'s actual order (required, then
the operator rule, then the string arm with its #29133 / #29241 shape
rules, numbers, boolean, temporal, select, multi-value, value shape) and
names the #29244 and #29562 select rules.
- **Changeset** `.changeset/29562-option-field-scalar-stored-string.md`:
`@objectstack/objectql` patch, `fix(objectql)!:`, the two declaration
lines, ADR-0087 `not-required`, a BREAKING banner with FROM → TO and the
caller fix, the `#29241` shape.
- No `packages/spec/**`, no new error code, no filter-door change, no
`protocol.ts`.
## Tests
-
`packages/objectql/src/validation/record-validator.option-scalar-value.test.ts`
(new): the door on all five option types and on `multiple: true` select
/ radio, per scalar (`3`, `0`, `-1.5`, `true`, `false`) and per element;
membership on the written string (a declared code `"3"` admits `3`,
codes a/b still refuse it); readonly fields written too; the untouched
cases; the object refusal (Date, Map, Set, class instance) on
single-value and element, insert and update, readonly; the engine doors
end to end with a recording driver (insert, insertMany, update by id,
update by predicate), the non-plain refusal with no driver write, and
the unique key seeing one value.
- `packages/rest/src/rest-data-option-scalar-value.test.ts` (new): REST
POST / PATCH on a real SQLite `SqlDriver`, physical column read past
every engine coercion: single, radio, declared code, multi-value
elements and a lone scalar; `invalid_option` still answered with nothing
written; the unique narrowing (`3`/`"3"`, `"3"`/`3`, `true`/`"true"` →
409) and SQLite's `true`/`1` widening; and a **filter-face pin that
records today's answer only** (`{ stage: 3 }` matches no row once both
rows hold `"3"`), naming #29486.
- **Forced test edit (one):** `record-validator.select-shape.test.ts`,
the `{ free: 3 }` control in `what the rule leaves alone`. It pinned the
number admission as present behaviour and named this card. It is
restated as the new rule: still admitted by the validator, and written
by the door as `{ free: "3" }`.
- driver-memory: no new test consumer (its test consumers are a closed
census, `check:driver-memory-census`). The memory column was measured
(table below), and the recording-driver pin shows what every driver
receives.
## Acceptance notes
1. **Step 0 table**, base `d3175d59` vs head, each row on engine insert
/ update by id / update by predicate and REST POST / PATCH, both drivers
(1100 cells per side; the two drivers agree on every answer):
| field · value | base memory | base SQLite | head (both) |
|:--|:--|:--|:--|
| free select / radio · `3` / `true` | `3` / `true` | `"3.0"` / `"1.0"`
| `"3"` / `"true"` |
| select, codes `"3"`,`"true"` · `3` / `true` | admitted, `3` / `true` |
admitted, `"3.0"` / `"1.0"` | admitted, `"3"` / `"true"` |
| select, codes a/b · `3` | `invalid_option` | `invalid_option` |
`invalid_option` |
| multi option (5 types) · `[3]` or lone `3` | `[3]` | `[3]` (JSON) |
`["3"]` |
| free select / radio · Date / Map / instance (engine) | stored as sent
| epoch / `"{}"` / JSON text | `invalid_type` |
| declared select / multiselect · Date / Map / instance |
`invalid_option` | `invalid_option` | `invalid_type` |
| free multi option · `[Date]` / `[Map]` / `[instance]` | ISO text /
`{}` / JSON | same | `invalid_type` |
| unique select · `3` then `"3"` (and reverse; `true` then `"true"`) |
201, 201 | 201, 201 | 201, 409 |
| unique select · `true` then `1` | 201, 201 | 201, 409 | 201, 201 |
A non-plain object cannot reach REST (a JSON body carries none); it
reaches the engine doors (flows, hooks, server code).
2. **Writer census: 0 product writers.** A log-only probe at the door
(every option-family field receiving a number, a boolean, a non-plain
object, or an array holding one) in a second worktree at base
`d3175d59`, objectql rebuilt there, then each suite with the probe's env
set: objectql (all), rest (all), service-automation (all), every package
under `packages/plugins/**`, runtime (local tier) and the full dogfood
battery (397 files, 3277 tests). Hits: 0 in every leg. Positive controls
hit: the Step 0 probe script against that tree's `dist` (658 lines) and
a throwaway vitest file against its `src` (1 line). So no product writer
and no existing test sends a number or boolean to an option field
through the engine, and the seat's ruling A needs no re-rule.
3. **Filter face, measured and not changed** (#29486's decision box). A
row written `9` and a row written `"9"`: base `{ free: 9 }` answered the
`9` row only, and `{ free: "9" }` the `"9"` row only; head `{ free: 9 }`
answers no row and `{ free: "9" }` both, on both drivers. Named in
open_questions.
4. **Stored rows** are not rewritten: an old raw `3` (driver-memory with
persistence) or SQLite `"3.0"` keeps reading as it is and does not
collide with a new `"3"`. Whether `os migrate value-shapes` should carry
option fields is an open question, not built.
5. **Out of scope, noted:** the text arm still admits a non-plain object
(a `Date` on a `text` field passes as its string reading and is stored
as sent); this card's ruling is option-family only. carrier: none.
## Verification
Head `6f164722`: the fix commits `271c2665` (code) and `ca133d57`
(tests, changeset) plus a merge of `origin/main` @ `0ab4a09e` (no
conflict; main's objectql moves are in `engine.ts` /
`engine-reference-name-visible.ts`, none in `record-validator.ts`).
Every run below is on `6f164722` after a full `turbo run build
--filter='./packages/**' --filter='./examples/**'` under
`os-verify-lock` (VERDICT command-exit 0), unless marked.
<details>
<summary>Suites on the head (each under the lock, VERDICT command-exit
0)</summary>
```text
objectql vitest run (all) Test Files 542 passed (542) · Tests 10577 passed · typecheck + check:test-typecheck OK
rest vitest run (all) Test Files 384 passed (384) · Tests 7405 passed | 352 skipped · typecheck OK
driver-memory test Test Files 74 passed (74) · Tests 1767 passed
driver-sql test Test Files 236 passed | 12 skipped (248) · Tests 3847 passed | 214 skipped
runtime vitest --project local Test Files 527 passed (527) · Tests 8503 passed | 19 skipped · typecheck OK
dogfood pnpm --filter @objectstack/dogfood test Test Files 398 passed | 3 skipped (401) · Tests 3280 passed | 28 skipped
```
</details>
<details>
<summary>Ablations (each restored: blob == HEAD, `git diff HEAD`
empty)</summary>
```text
A single-value arm: isObjectValue(value) -> isPlainObjectValue(value) || Array.isArray(value)
objectql option-scalar-value.test.ts Tests 35 failed | 49 passed (84) e.g. "free: refuses a Date on insert as invalid_type": expected [] to deeply equal [['free','invalid_type']]
B element arm: value.find(isObjectValue) -> value.find((v) => isPlainObjectValue(v) || Array.isArray(v))
objectql option-scalar-value.test.ts Tests 21 failed | 63 passed (84) e.g. "free_multi: refuses an element that is a Date as invalid_type"
C door: isOptionType -> false (marker __ablation_29562__), objectql rebuilt, dist preflight found the marker
objectql option-scalar-value + select-shape Tests 25 failed | 102 passed (127)
rest rest-data-option-scalar-value.test.ts Tests 8 failed | 2 passed (10)
"3, then \"3\"": the second row landed holding "3" beside "3.0", expected 201 to be 409
"\"3\", then 3": the second row landed holding "3.0", expected 201 to be 409
"true, then 1": 409 UNIQUE_VIOLATION, expected 201 (SQLite's base "1.0" collision)
restored, objectql rebuilt, ablation-dist-preflight --absent: marker absent from all 14 built files, tree clean
```
</details>
<details>
<summary>Gates and lint (after the last commit, on 6f16472)</summary>
```text
node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack 61 derived
each run, exit captured before any pipe; --ran reconciliation:
61 derived, 61 run, 0 NOT-MEASURED, 0 UNRUN
two first-run exit 3 (prerequisite, not a finding), re-run exit 0:
check-plugin-teardown-shape --self-test (fixture commit 621a487 fetched)
check:type-check-debt (first run overlapped a closure build; re-measure OK, 0 raw tsc errors above ledger)
eslint packages/objectql packages/rest --no-inline-config exit 0
```
</details>
---
_Generated by [Claude
Code](https://claude.ai/code/session_01ADHkmSLV8FibVzx9B1ZHNn)_
---------
Co-authored-by: Claude <noreply@anthropic.com>
1 parent 14df37f commit b391feb
5 files changed
Lines changed: 626 additions & 33 deletions
File tree
- .changeset
- packages
- objectql/src/validation
- rest/src
| 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 | + | |
| 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 | + | |
| 175 | + | |
| 176 | + | |
| 177 | + | |
| 178 | + | |
| 179 | + | |
| 180 | + | |
| 181 | + | |
| 182 | + | |
| 183 | + | |
| 184 | + | |
| 185 | + | |
| 186 | + | |
| 187 | + | |
| 188 | + | |
| 189 | + | |
| 190 | + | |
| 191 | + | |
| 192 | + | |
| 193 | + | |
| 194 | + | |
| 195 | + | |
| 196 | + | |
| 197 | + | |
| 198 | + | |
| 199 | + | |
| 200 | + | |
| 201 | + | |
| 202 | + | |
| 203 | + | |
| 204 | + | |
| 205 | + | |
| 206 | + | |
| 207 | + | |
| 208 | + | |
| 209 | + | |
| 210 | + | |
| 211 | + | |
| 212 | + | |
| 213 | + | |
| 214 | + | |
| 215 | + | |
| 216 | + | |
| 217 | + | |
| 218 | + | |
| 219 | + | |
| 220 | + | |
| 221 | + | |
| 222 | + | |
| 223 | + | |
| 224 | + | |
| 225 | + | |
| 226 | + | |
| 227 | + | |
| 228 | + | |
| 229 | + | |
| 230 | + | |
| 231 | + | |
| 232 | + | |
| 233 | + | |
| 234 | + | |
| 235 | + | |
| 236 | + | |
| 237 | + | |
| 238 | + | |
| 239 | + | |
| 240 | + | |
| 241 | + | |
| 242 | + | |
| 243 | + | |
| 244 | + | |
| 245 | + | |
| 246 | + | |
| 247 | + | |
| 248 | + | |
| 249 | + | |
| 250 | + | |
| 251 | + | |
| 252 | + | |
| 253 | + | |
| 254 | + | |
| 255 | + | |
| 256 | + | |
| 257 | + | |
| 258 | + | |
| 259 | + | |
| 260 | + | |
| 261 | + | |
| 262 | + | |
| 263 | + | |
| 264 | + | |
| 265 | + | |
| 266 | + | |
| 267 | + | |
| 268 | + | |
| 269 | + | |
| 270 | + | |
| 271 | + | |
| 272 | + | |
| 273 | + | |
| 274 | + | |
| 275 | + | |
| 276 | + | |
| 277 | + | |
| 278 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
24 | 24 | | |
25 | 25 | | |
26 | 26 | | |
27 | | - | |
| 27 | + | |
28 | 28 | | |
29 | 29 | | |
30 | 30 | | |
| |||
117 | 117 | | |
118 | 118 | | |
119 | 119 | | |
120 | | - | |
121 | | - | |
122 | | - | |
| 120 | + | |
| 121 | + | |
| 122 | + | |
123 | 123 | | |
| 124 | + | |
124 | 125 | | |
125 | 126 | | |
126 | 127 | | |
| |||
0 commit comments