Repository navigation
Commit 2626bdd
fix(metadata-protocol): a plain clone does not report the source's empty readonly columns as dropped fields (#29290)
<!-- claude-projects-attribution: {"github_login":"os-steve"} -->
_Requested by **Steve** · [project
thread](https://claude.ai/code/project/chan_01FdL1XeUyvVNxLreh3F4Qi9?thread=cmsg_01FdL1XeUyvVNxLreh3F4Qi935ZoSza1EBztq9TAUZYgTk)_
Fixes #29200
Clause-②: no
Metadata-protocol: none
## 维护者速读
-
**改了什么**:克隆记录时,源记录里为空的只读字段不再被复制进新记录的插入数据,所以一次什么都没改的克隆不会再在响应里报告"字段被丢弃",`X-ObjectStack-Dropped-Fields`
响应头也不再出现。
- **为什么改**:控制台每次克隆一条只读字段为空的记录(如 showcase 联系人的
`lead_score`),都会提示"某些字段被丢弃";集成方读 `droppedFields`
或响应头,会把每次克隆都当成丢了数据。用户其实什么都没发。
-
**风险与回滚**:存下来的新记录与修复前逐字节相同(空值本来就会被剥掉、插入时空值与缺省同样取默认值)。只读字段有值时照旧复制、剥离并报告(#15703
裁决),`overrides` 里带只读字段也照旧报告。回滚即 revert 本 PR。
- **先看**:`packages/metadata-protocol/src/protocol.ts` 的
`cloneData`(新增一行与注释);`packages/rest/src/rest-dropped-fields-every-write-door.test.ts`
的 clone 用例(新增 3 个)。
- **两分钟验证**:`pnpm --filter @objectstack/rest exec vitest run
src/rest-dropped-fields-every-write-door.test.ts`,预期 18 个用例全过(需先构建
`@objectstack/metadata-protocol`)。
- **需要你做的**:无,席位落地。
## What changes
`cloneData` (`packages/metadata-protocol/src/protocol.ts`) copied every
column of the source row into the insert, read-only ones included, even
where the source held `null`. `engine.insert`'s static-readonly strip
judges every present key as caller-supplied, so it took that empty key
and reported it. A plain clone (no `overrides`) of a row whose read-only
column was empty therefore answered `droppedFields` naming that column,
and since #29034 the `X-ObjectStack-Dropped-Fields` header repeated it.
The fix is on the producer side, as the contract review on #29034 ruled:
in the loop that already removes engine-owned columns from the copy, a
field declared `readonly` whose copied value is `null`/`undefined` is
now left out of the insert payload. Nothing changes in
`packages/rest/src/error-response.ts`; the header still mirrors the
body.
| clone | body `droppedFields` before → after | header before → after |
|:--|:--|:--|
| no overrides, source's read-only column empty | names the column →
absent | names the column → absent |
| no overrides, source's read-only column holds a value | names the
column → names the column | `ro;reason=readonly` → same |
| `overrides` sets the read-only field | names the column → names the
column | `ro;reason=readonly` → same |
**Why the stored row is unchanged.** For a non-system caller the strip
removed the empty key anyway. For every caller, the insert's
`applyFieldDefaults` treats an explicit `null` exactly as an omitted key
(`out[f.name] != null` is its only skip), so the copy takes the same
default either way. Injected platform columns (`organization_id`, owner,
audit) are `system: true` and were already removed from the copy by the
existing branch.
**Acceptance 3 (a read-only column that holds a value) — measured,
kept.** On `main` at `45e9b2f1`, a plain clone of a row whose `ro` holds
`'seeded'` answers 201 with `droppedFields: [{ object, fields: ['ro'],
reason: 'readonly' }]` and header `ro;reason=readonly`; after the fix it
answers the same (the new control case passes on both trees). This is
not the same defect: the value was really copied from the source and
really stripped, so the caller reads the default back, and the
maintainer ruling on #15703 (option 1, record 5578819560) is exactly
that the clone reports this strip so the caller is told why. The card's
route (a) is therefore applied to EMPTY read-only values only; dropping
valued read-only columns from the copy would silence a report that
ruling requires.
Route (b) (the engine strip not reporting a present `null` key) was not
taken: it changes the #3431 "fields stripped from THIS write" report on
every insert path, and a caller that really sends `ro: null` on a create
did send that key.
## Why
The console announces every non-empty `droppedFields`, so users saw a
"fields dropped" notice on a clone where they changed nothing, and
integrations reading the body or the header would treat every such clone
as data loss.
## Tests
- `packages/rest/src/rest-dropped-fields-every-write-door.test.ts` (real
`ObjectQL` + protocol + `RestServer` over better-sqlite3), three new
clone cases on a new seed row `src_empty` (read-only `ro` left empty):
- a plain clone of `src_empty`: 201, no `droppedFields`, no header,
`record.ro` null (red on `main`);
- `overrides: { ro: 'forged' }` on `src_empty`: still reported, body and
header (control, acceptance 2);
- a plain clone of `src1` (`ro: 'seeded'`): still reported, body and
header (control, acceptance 3, #15703).
- `packages/qa/dogfood/test/showcase-static-readonly.dogfood.test.ts`:
new case, a plain clone of a stock showcase contact whose `lead_score`
is empty answers no `droppedFields` and no header.
**Forced test edits** (assertions that accommodated the defect):
- dogfood `clone with overrides.lead_score`: the header assertion moves
from `toContain('lead_score;reason=readonly')` to
`toBe('lead_score;reason=readonly')`, and its comment ("a plain clone
copies the source's other readonly columns too, so the header may name
more than lead_score") is rewritten, since the copy no longer carries
empty read-only columns.
- rest test file: the comment above `CLONE_OBJECT` ("a clone of a PROJ
row also reports the source's `ro2`") is rewritten to say only valued
read-only columns are copied; no assertion moved. The seed gains one row
(`src_empty`); no case counts that object's rows.
## Acceptance notes
- Serial note: open PR #28731 edits `protocol.ts` at `probeRecord`; this
diff touches only `cloneData`.
- The showcase cannot exercise acceptance 3 (its only read-only column,
`lead_score`, cannot be given a value by a non-system caller); the
real-stack rest test carries it.
## Verification
Head: `85ebba60` (merge of `origin/main` at `f6a04e3a` over fix
`f825d6bd`; red-first base `45e9b2f1`).
<details><summary>Red first, fix, ablation</summary>
- Red on `main` (`45e9b2f1`, tests only, `@objectstack/rest^...` built):
`rest-dropped-fields-every-write-door.test.ts` Tests 1 failed | 17
passed (18); the failure is the new plain-clone case, `expected {
object: 'hdr_drops_clone', ... } to not have property "droppedFields"`.
The valued-column and overrides controls passed on `main`.
- Fix (`f825d6bd`, `metadata-protocol` rebuilt): Tests 18 passed (18).
- Ablation through `scripts/ablation-replace.mjs` (anchor hit 1 -> 0,
blob changed), the new line made dead by a `name ===
'__ABLATION_29200__'` guard; `ablation-dist-preflight.mjs` found the
marker in `dist/index.js` and `dist/index.cjs`. Rest pin: Tests 1 failed
| 17 passed (the plain-clone case). Dogfood file: Tests 1 failed | 8
passed (the new plain-clone case, body named `lead_score`). Restore leg
each time: blob == HEAD, `git diff HEAD` empty, rebuilt, preflight
`--absent` exit 0, Tests 18/18 and 9/9.
</details>
<details><summary>Package suites and gates</summary>
- `pnpm --filter @objectstack/metadata-protocol test && typecheck`: Test
Files 300 passed, 4 skipped (304); Tests 22100 passed, 26 skipped;
VERDICT command-exit 0 (at `f825d6bd`).
- `pnpm --filter @objectstack/rest test && typecheck`: Test Files 367
passed (367); Tests 6966 passed, 332 skipped; VERDICT command-exit 0 (at
`f825d6bd`).
- After merging `origin/main` (`85ebba60`): `pnpm install
--frozen-lockfile`, build of the `@objectstack/rest^...` and
`@objectstack/dogfood^...` closures, `pnpm --filter @objectstack/spec
check:generated`: VERDICT command-exit 0. Rest pin Tests 18/18, dogfood
`showcase-static-readonly` Tests 9/9.
- `node scripts/pm/dispatch-gates.mjs --commands`: 65 derived; all 65
recorded `exit 0` at `85ebba60` (`check:dual-build-cjs-loads` first exit
3, PREREQUISITE NOT MET, re-run exit 0 after building `./packages/**`).
`--ran`: 65 run, 0 NOT-MEASURED, 0 UNRUN.
- eslint (`--no-inline-config`) on the three touched source and test
files: exit 0.
- Full dogfood battery: not owed (no `!`, no narrowing arm).
</details>
## Human Minimum
- [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic.
- [x] 2. Every name I added uses the casing for its kind.
- [x] 3. No formatter ran; `pnpm lint` passes.
- [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: its generated artifacts are
regenerated and committed. (not touched)
- [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.
- [x] The *Before Submitting* list in CONTRIBUTING.md is done.
---
_Generated by [Claude
Code](https://claude.ai/code/session_01ADHkmQZyRQCzui7zekMYY7)_
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent 5780fa4 commit 2626bdd
4 files changed
Lines changed: 78 additions & 6 deletions
File tree
- .changeset
- packages
- metadata-protocol/src
- qa/dogfood/test
- 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 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
14670 | 14670 | | |
14671 | 14671 | | |
14672 | 14672 | | |
| 14673 | + | |
14673 | 14674 | | |
| 14675 | + | |
| 14676 | + | |
| 14677 | + | |
| 14678 | + | |
| 14679 | + | |
| 14680 | + | |
| 14681 | + | |
| 14682 | + | |
| 14683 | + | |
| 14684 | + | |
| 14685 | + | |
14674 | 14686 | | |
14675 | 14687 | | |
14676 | 14688 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
148 | 148 | | |
149 | 149 | | |
150 | 150 | | |
151 | | - | |
152 | | - | |
153 | | - | |
| 151 | + | |
| 152 | + | |
| 153 | + | |
| 154 | + | |
| 155 | + | |
| 156 | + | |
| 157 | + | |
| 158 | + | |
| 159 | + | |
| 160 | + | |
| 161 | + | |
| 162 | + | |
| 163 | + | |
154 | 164 | | |
155 | 165 | | |
156 | 166 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
54 | 54 | | |
55 | 55 | | |
56 | 56 | | |
57 | | - | |
58 | | - | |
59 | | - | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
60 | 61 | | |
61 | 62 | | |
62 | 63 | | |
| |||
116 | 117 | | |
117 | 118 | | |
118 | 119 | | |
| 120 | + | |
| 121 | + | |
119 | 122 | | |
120 | 123 | | |
121 | 124 | | |
| |||
147 | 150 | | |
148 | 151 | | |
149 | 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 | + | |
150 | 185 | | |
151 | 186 | | |
152 | 187 | | |
| |||
0 commit comments