Repository navigation
Commit 04e0c2c
domain:server#1 · os-steve · session_01ADHkmQjPiXgNoSaEpqeGvY
<!-- claude-projects-attribution: {"github_login":"os-steve"} -->
_Requested by **Steve** · [project
thread](https://claude.ai/code/project/chan_01FdL1XeUyvVNxLreh3F4Qi9?thread=cmsg_01FdL1XeUyvVNxLreh3F4Qi9D6hLaUChirwQbD3RzAKEBf)_
Fixes #29241
Clause-②: yes (narrowing)
Metadata-protocol: none
Skills-docs: none — no file under `skills/**` or `content/docs/**`
teaches writing a number or boolean into a text-family field, or how a
`unique` text field compares `42` with `"42"` (a `git grep` of
`skills/**` for unique and text/number shows none); the field-types rule
already says a text field stores a string.
Clause-② as amended by seat ruling 6579176393 on #29241. Measured: a
single write is answered as before (the same payloads are admitted and
refused). The unique key now compares the stored string, so on a
`unique` text-family field the accept set of a write sequence moves in
both directions (table under Why). Metadata-protocol stays `none`: no
key, value or metadata type an author writes moves.
> **BREAKING** for one case only, a `unique` text-family field. FROM: `{
code: 42 }` then `{ code: "42" }` (or `true` then `"true"`) both
answered `201` and stored two rows, on driver-memory and on SQLite. TO:
both store `"42"`, so the second write is refused `409
UNIQUE_VIOLATION`. The fix for a caller: the two writes are the same
value; send it once, or update the existing row. The changeset carries
one ADR-0087 marker, `not-required (no-migration-prescription)`: no
metadata moves, and rows already stored are not rewritten, so nothing is
prescribed. `check-adr-0087-registration` reports `1 declared-breaking
changeset(s), each carrying an ADR-0087 disposition …
[BREAKING+bang+clause-②-narrowing] not-required
(no-migration-prescription)`.
## 维护者速读
- **改了什么**:数字或布尔值写进文本类字段(text、textarea、email、phone 等 12
种)时,服务端写入口先把它改写成校验器判定用的那个字符串(`42` 存成 `"42"`,`true` 存成 `"true"`),再交给驱动。
- **为什么改**:校验器一直按字符串 `"42"` 判定,存的却是原值,结果因驱动而异:内存驱动存数字 `42`,SQLite 存成
`"42.0"`、`true` 存成 `"1.0"`(`maxLength: 2` 的字段放行了 `42`,最后存了 4
个字符)。同一次写入,三种存法,等值过滤和排序因库而异。
- **风险与回滚**:单次写入的放行与拒绝不变。变化在唯一(unique)文本字段上:先写 `42` 再写
`"42"`,以前两条都存,现在第二条 `409 UNIQUE_VIOLATION`(两个驱动都如此,这是 BREAKING);SQLite
上先写 `true` 再写 `1` 以前被拒,现在两条都存。已存的行不迁移,SQLite 旧行 `"42.0"` 和新写的 `"42"`
不再相等。回滚 = revert 本 PR。
- **先看**:`packages/objectql/src/validation/record-validator.ts` 的
`normalizeNumericStringRow` 新的 `else if`
分支;`packages/rest/src/rest-data-text-scalar-value.test.ts`;`.changeset/29241-text-field-scalar-stored-string.md`。
- **两分钟验证**:`pnpm --filter @objectstack/rest exec vitest run
src/rest-data-text-scalar-value.test.ts`,预期 8 个全绿(含唯一字段的 4 个:两个 409、两个双
201)。
- **需要你做的**:无,席位落地。
## What changes
- **The seam.** `normalizeNumericStringValues` in
`packages/objectql/src/validation/record-validator.ts`, the #20309 door
that writes a numeric string on a number field as its number.
`ObjectQL.insert()`, `update()`, `validate()` (the dry run) and
`validateInsertValues` already call it on the caller's payload before
the middleware, the snapshots, the hooks and the validator read it.
Every REST, batch and import door goes through those methods.
`engine.ts` is not touched, and the blank-value path
(`normalizeBlankTypedValues`) is not touched.
- **What moves (Clause-②).** Hooks, formulas, filters and a driver's
unique check all now see the string. On a `unique` text-family field
that changes which write sequences are admitted, both ways; see the
second table under Why.
- **The new arm.** A number or a boolean on a field whose type is in the
spec's `BOUNDED_STRING_FIELD_TYPES` (the set the validator's string arm
reads) is written as `String(value)`, the exact string the arm judges.
The field scope is the numeric half's: the fields the validator walks,
`readonly` included, never a `SKIP_FIELDS` name or a `system` field.
- **Unchanged, and pinned:** a string, `null`, a missing key, an object
or an array (refused since #29133), `json` and the structured types,
number and boolean fields, a field the validator never judges, an
undeclared key. `select` and the reference types are not in the set
(#29244 owns the select arm).
- The string arm's comment now says the door has written the judged
string.
## Why
Measured on `origin/main` @ `a19759ac` (base) and on this branch (head),
through the real REST routes (`POST /data/:object`, then the physical
row, `engine.findOne` and `GET /data/:object/:id`):
| Sent on a `text` / `textarea` field | driver-memory, base | driver-sql
(SQLite), base | both drivers, head |
|---|---|---|---|
| `42` / `true` | `42` / `true` (number, boolean) | `"42.0"` / `"1.0"` |
`"42"` / `"true"` |
| `false` / `0` | `false` / `0` | `"0.0"` / `"0.0"` | `"false"` / `"0"`
|
| `1.5` / `-3` | `1.5` / `-3` | `"1.5"` / `"-3.0"` | `"1.5"` / `"-3"` |
| `"x42"` / `"true"` (control) | as sent | as sent | as sent |
All three reads (physical row, engine, REST) agree within each cell.
Every write answered `201` on base and head.
On SQLite the stored value is neither the raw scalar nor the judged
string: a number is bound as a REAL and the TEXT column keeps its REAL
spelling, and a boolean is bound as `1`/`0`. So a `maxLength: 2` text
field admitted `42` and stored four characters, and `{ title: true }`
stored `"1.0"`. PostgreSQL and MongoDB: NOT MEASURED (no daemon in the
container). The engine now hands every driver the string, so no driver
decides.
**Who writes a number or boolean into a text-family field today**
(option A vs B). A log-only probe at the new arm recorded every rewrite
while these suites ran: `@objectstack/objectql`, `@objectstack/rest`,
`@objectstack/runtime`, `@objectstack/service-automation` (the flow
nodes, including the `create_record` / `update_record` and screen paths)
and the dogfood battery, which boots the example apps and their seeds.
Positive control: the probe was proven in `dist/` (4 built files) and
recorded the 6 writes of the new REST test, which reach objectql through
`dist/`.
| Writer | Hits |
|---|---|
| Seeds and data in `examples/`, `packages/apps`, platform objects
(booted by the dogfood battery and the runtime suite) | 0 |
| Flow / screen paths (`service-automation` suite, dogfood flow
fixtures) | 0 |
| Dogfood fixtures | 0 |
| Tests: `protocol-save-notice-answer.test.ts` (a `text` field used to
hand a raw `42` to a hook) | 1 |
No product writer under these suites relies on the raw scalar, so A was
built as the card's default. The census did not cover the plugin suites
(for example plugin-audit and service-messaging) or the other service
packages, so it is not exhaustive; the review spot-read the obvious
text-typed value columns there (`sys_audit_log.old_value` / `new_value`
are written through `safeStringify`) and found no number writer.
**A unique text-family field** (measured: the REST create route, the
base leg with `origin/main` @ `7f277a86`'s `record-validator.ts` built
into objectql's `dist/`, the head leg with this branch's; SQLite in
`packages/rest`, driver-memory in a one-off probe beside it, deleted):
| Sequence (two POSTs) | base, SQLite | base, driver-memory | head, both
drivers |
|---|---|---|---|
| `42`, then `"42"` | 201, 201 | 201, 201 | 201, **409**
`UNIQUE_VIOLATION` |
| `"42"`, then `42` | 201, 201 | 201, 201 | 201, **409** |
| `true`, then `"true"` | 201, 201 | 201, 201 | 201, **409** |
| `true`, then `1` | 201, 409 | 201, 201 | 201, **201** |
| `false`, then `0` | 201, 409 | 201, 201 | 201, **201** |
| `1`, then `true` | 201, 409 | 201, 201 | 201, **201** |
| `42`, then `"42.0"` | 201, 409 | 201, 201 | 201, **201** |
The first three rows are the narrowing, on both drivers: the validator
reads both values as one string, and now the unique key does too. The
last four are SQLite's widening: base stored `true` and `1` both as
`"1.0"`, and `42` as `"42.0"`. Each new answer is the one the
validator's reading implies.
## Tests
-
`packages/objectql/src/validation/record-validator.text-scalar-value.test.ts`
(70 tests): the spec set (12 types) × `42`, `0`, `-1.5`, `true`,
`false`; the written string is the one a `maxLength` reads; a readonly
field; a batch per row, caller objects unmutated; the controls listed
above; and the engine doors (insert, insertMany, update by id, update by
predicate) with a recording driver, which receives the string; and a
`unique` text field, where `42` and `"42"` reach the driver as one value
(what driver-memory's unique key compares). The `validate()` dry run
calls the same function; its answer carries no values, so it is not
pinned here.
- `packages/rest/src/rest-data-text-scalar-value.test.ts` (8 tests):
real `ObjectQL` + SQLite `SqlDriver` + `RestServer`, POST, PATCH and
batch create, checked on the physical column, `findOne` and `GET …/:id`;
a string control and a number-field control; and the unique field in
both directions: `42` then `"42"` and `true` then `"true"` refused `409
UNIQUE_VIOLATION` (status and code asserted), `true` then `1` and
`false` then `0` both landing as two strings.
- **One forced test edit:**
`packages/objectql/src/protocol-save-notice-answer.test.ts`. Its
`refuses a number` case sent `say: 42` through a `text` field so the
hook would call `ctx.notice(42)`. The hook now receives `"42"`, so the
vehicle field `say` is `json`, which keeps the value as sent. The
assertion is unchanged, and the file's other 21 tests are green on it.
<details><summary>Red on base, green on head</summary>
| Run | Base `a19759ac` | Head |
|---|---|---|
| rest file, first 4 tests | `Tests 3 failed, 1 passed (4)`: `expected
'42.0' to be '42'`, `expected '0.0' to be '0'`, `expected '1.0' to be
'true'` | `Tests 4 passed (4)` |
| rest file, the 4 unique tests | measured by the probe table under Why
(same routes, same SQLite boot): 42/"42" and true/"true" both 201,
true/1 and false/0 second 409 | `Tests 8 passed (8)` at `926fa801` |
</details>
<details><summary>Ablations (the fix, the three negative pins, and the
unique pins)</summary>
Each mutation was made with `scripts/ablation-replace.mjs` (anchor 1 →
0, blob changed) and restored to the HEAD blob `bdeb597b` with `git diff
HEAD` empty. The objectql file resolves `src/` directly; the rest file
reads objectql from `dist/`, so leg 1 rebuilt objectql and
`ablation-dist-preflight.mjs` found the marker in 4 built files, then
absent from all 14 after the restore rebuild.
| Leg | Mutation | Red |
|---|---|---|
| 1 | the arm writes the raw value again | objectql `Tests 64 failed, 5
passed (69)`; rest `Tests 3 failed, 1 passed (4)` (`'42.0'`, `'0.0'`,
`'1.0'`) |
| 2 | the rewrite reaches every field type | `a json, number or boolean
field keeps its number or boolean`; the engine-door test (its `amount:
7` number-field control) |
| 3 | the validator-walk scope dropped | `a declared field the validator
never judges is not rewritten` |
| 4 | null / missing / object / array reach the rewrite | `a string,
null, a missing key, an object and an array on a text field: the same
row back` |
| 1, round 2 (head `926fa801`) | the arm writes the raw value again
(marker in 4 built files, then absent from 14; restored to blob
`92fe1708`, `git diff HEAD` empty) | rest `Tests 7 failed, 1 passed
(8)`: both 409 pins, both 201,201 pins and the three stored-form tests;
objectql `a unique text field: 42 and "42" reach the driver as one
value` |
</details>
## Acceptance notes
- **Stored rows are not rewritten.** A SQLite row written before this
change keeps `"42.0"` and no longer equals a new `"42"`, nor collides
with it on a unique field. driver-memory keeps rows for the process by
default, but its `persistence` option (`file`, `local` or a custom
adapter) is opt-in and keeps raw `42` / `true` across restarts the same
way. A migration for such rows would be a separate card.
- **The filter door** (measured, the same two runs, one row written as
`42` and one as `"42"`, `engine.find` and `POST /data/:object/query`
agreeing): base: `{ title: 42 }` matched only the row written as `42` on
both drivers (driver-memory's raw `42`, SQLite's `"42.0"`), and `{
title: "42" }` only the other. Head: `{ title: "42" }` matches both
rows, and `{ title: 42 }` matches none, on both drivers. So at head a
number comparand on a text field does not answer differently by driver;
it silently matches nothing, while the write door stores that number as
its string. The seat ruled it (b), a gap that predates this PR, to be
filed as its own card after landing; the changeset tells a caller to
filter with the string.
- **Other non-string values on a text field.** The string arm also
judges a `Date` (or another class instance) by `String(value)` while the
raw object reaches the driver. Out of this card's scope (numbers and
booleans). Reported to the seat, not filed.
- **A `before*` hook** that writes a number into a text field after the
door is the hook's own value and is not rewritten, as on the numeric
half.
## Verification
Head `288190cd` (round 3: prose only — the changeset's filter sentence,
and the record-validator docblock and objectql test header restated to
the unique answer; no code or test line changed. Round 2 head
`926fa801`: the unique pins and the declaration; round 1 head `34c30195`
had merged `origin/main` with #29271, `merge-tree` clean). See the PR's
check-runs for CI.
<details><summary>Suites, typecheck, gates, lint, dogfood</summary>
| Run | Head | Result |
|---|---|---|
| `pnpm --filter @objectstack/dogfood test` (closure built at this head)
| `926fa801` | lock VERDICT command-exit 0 · Test Files 393 passed, 3
skipped (396) · Tests 3243 passed, 28 skipped (3271) |
| objectql `record-validator.text-scalar-value.test.ts` | `926fa801` |
Tests 70 passed (70) |
| rest `rest-data-text-scalar-value.test.ts` | `926fa801` | Tests 8
passed (8) |
| `dispatch-gates.mjs --commands` (61 families), each run | `926fa801` |
61 × exit 0; `--ran`: 61 derived, 61 run, 0 NOT-MEASURED |
| `check-adr-0087-registration --base origin/main` | `288190cd` | exit
0: 1 declared-breaking changeset with its disposition, `not-required
(no-migration-prescription)` |
| eslint (`--no-inline-config`) over `packages/objectql` and
`packages/rest` | `926fa801` | exit 0 (neither package has its own
`lint` script) |
| round 3 prose checks: `check:doc-authoring`, `check:issue-citations`,
`check:nul-bytes`, `check-empty-changeset`,
`check-closing-keyword-parity`, eslint on
`packages/objectql/src/validation` | `288190cd` | each exit 0 |
| `pnpm --filter @objectstack/objectql test` | `34c30195` | Test Files
531 passed (531) · Tests 10345 passed (10345) |
| `pnpm --filter @objectstack/objectql typecheck` | `34c30195` | exit 0
|
| `pnpm --filter @objectstack/rest test` | `34c30195` | Test Files 371
passed (371) · Tests 7034 passed, 332 skipped (7366) |
| `pnpm --filter @objectstack/rest typecheck` | `34c30195` | exit 0 |
| `pnpm --filter @objectstack/runtime test` | `27920d0a` + log-only
probe | Test Files 516 passed (516) · Tests 8367 passed, 19 skipped |
| `pnpm --filter @objectstack/service-automation test` | `27920d0a` +
log-only probe | Test Files 259 passed (259) · Tests 3335 passed |
Round 2 (`34c30195` → `926fa801`) changed only the changeset and the two
new test files, so the full objectql and rest suites were not re-run;
their round 2 readings are the two edited files above, and Test Core in
CI. The runtime and automation rows ran with the census probe in
objectql's `dist/`: one uncommitted line in the new arm that appended a
JSON line to a file when `OS_TEST_PROBE_29241` was set, and changed no
value; it was removed and `ablation-dist-preflight` proved it absent
from all 14 built files. The round 1 dogfood reading (`27920d0a` +
probe: 391 files, 3223 tests) is superseded by the head reading above.
</details>
## Human Minimum
- [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic.
(not touched)
- [x] 2. Every name I added uses the casing for its kind.
- [x] 3. No formatter ran; `pnpm lint` passes on the touched packages.
- [x] 4. One issue, one branch off an up-to-date `main`, one pull
request.
- [x] 5. A change that publishes carries a changeset, and both
declaration lines are filled in here and in the changeset.
- [x] 6. `packages/spec` touched: n/a.
- [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 (the maintainer brief
above is Chinese by repo rule).
---
_Generated by [Claude
Code](https://claude.ai/code/session_01ADHkmPGQiCkuYTyC61yVpF)_
---------
Co-authored-by: Claude <noreply@anthropic.com>
1 parent 6af3d29 commit 04e0c2c
5 files changed
Lines changed: 427 additions & 7 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 | |
|---|---|---|---|
| |||
36 | 36 | | |
37 | 37 | | |
38 | 38 | | |
39 | | - | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
40 | 43 | | |
41 | 44 | | |
42 | 45 | | |
| |||
| 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 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
945 | 945 | | |
946 | 946 | | |
947 | 947 | | |
| 948 | + | |
| 949 | + | |
| 950 | + | |
| 951 | + | |
| 952 | + | |
| 953 | + | |
| 954 | + | |
| 955 | + | |
| 956 | + | |
| 957 | + | |
| 958 | + | |
| 959 | + | |
| 960 | + | |
| 961 | + | |
| 962 | + | |
| 963 | + | |
| 964 | + | |
| 965 | + | |
| 966 | + | |
| 967 | + | |
| 968 | + | |
| 969 | + | |
| 970 | + | |
| 971 | + | |
948 | 972 | | |
949 | 973 | | |
950 | 974 | | |
| |||
970 | 994 | | |
971 | 995 | | |
972 | 996 | | |
973 | | - | |
| 997 | + | |
| 998 | + | |
974 | 999 | | |
975 | 1000 | | |
976 | | - | |
977 | | - | |
978 | | - | |
979 | | - | |
| 1001 | + | |
| 1002 | + | |
| 1003 | + | |
| 1004 | + | |
| 1005 | + | |
| 1006 | + | |
| 1007 | + | |
| 1008 | + | |
| 1009 | + | |
| 1010 | + | |
980 | 1011 | | |
981 | 1012 | | |
982 | 1013 | | |
983 | 1014 | | |
| 1015 | + | |
| 1016 | + | |
| 1017 | + | |
| 1018 | + | |
| 1019 | + | |
| 1020 | + | |
| 1021 | + | |
| 1022 | + | |
| 1023 | + | |
984 | 1024 | | |
985 | 1025 | | |
986 | 1026 | | |
| |||
1225 | 1265 | | |
1226 | 1266 | | |
1227 | 1267 | | |
1228 | | - | |
| 1268 | + | |
| 1269 | + | |
| 1270 | + | |
1229 | 1271 | | |
1230 | 1272 | | |
1231 | 1273 | | |
| |||
0 commit comments