Skip to content

fix(objectql): a write answers every stored declared column the driver did not echo as null, as the next read does (#29538) - #29858

Merged
objectstack-fleet[bot] merged 10 commits into
mainfrom
claude/project-thread-2polz0
Nov 3, 2026
Merged

objectstack-fleet[bot] merged 10 commits into
mainfrom
claude/project-thread-2polz0

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Requested by Warren · project thread
Fixes #29538

Clause-②: no
Metadata-protocol: none

维护者速读

  • 改了什么: 新建与按 id 更新的返回记录,对驱动没有回显的已存储声明列补 null,和紧接着的一次读取完全一致;本次写入自己带了值(或更新前已存值)的列不补 null,驱动没回显就照旧缺省。
  • 为什么改: mongo / memory 驱动上,新建或编辑后的返回体缺少未写的字段,而下一次 GET 同一行却把它们答成 null;客户端拿到同一条记录的两个不同版本,公式字段在返回体里还会报错为 null。
  • 风险与回滚: 只改返回体,不改存储内容,也不改读路径;区分「缺键」和「null」的读者现在读到 null(driver-sql 一直如此)。回滚即 revert 本 PR。
  • 先看: packages/objectql/src/engine.ts 插入结果处的 storedColumnsLeftUnwritten 调用(批量按行配对 payload);同文件按 id 更新回读处;packages/objectql/src/declared-read-columns.ts 新增的 storedColumnsLeftUnwritten。
  • 两分钟验证: pnpm --filter @objectstack/objectql exec vitest run src/write-answer-declared-columns.test.ts,预期 32 个用例全过。
  • 需要你做的: 无,席位落地。

What changes

  • The write answer is the next read's record. ObjectQL.insert (single, batch, and each insertMany outcome) and the by-id update readback now answer every stored declared column the driver did not echo as null, reusing objectql read path: a declared column the driver did not return stays an absent key, so mongo/memory rows are sparse where SQL rows are not #29408's selectedStoredColumns + withSelectedColumnsAnswered (no copy). Placed right after the existing withDeclaredColumnsOnly shaping, before the formulas and the after-hooks, so a formula over an unwritten column evaluates against null as the read does, and the security middleware's field mask still deletes an FLS-refused key afterwards. Every door on those two verbs answers alike: POST /data/:object, PATCH /data/:object/:id, createManyData, updateManyData, batchData create / update / both upsert arms.
  • Never a null the row does not hold (new in this round, storedColumnsLeftUnwritten in declared-read-columns.ts). A column the insert payload, or the PATCH and then the raw stored row read before it, carries a non-null value for is stored, so it is left out of the fill: a driver that stores it without echoing it leaves it absent, as before. A PATCH null stays answerable. On driver-sql, driver-memory and driver-mongodb the echo is the stored row, so this changes nothing there. It is what the consumer sweep asked for: trigger-record-change's flow record overlays the write result on the input ({ ...previous, ...input.data, ...after }, [P2] record-change context does not hydrate multi-lookup (junction) fields #1872), and a fabricated null there overwrote a written multi-lookup value (multilookup-context.test.ts red, below).
  • Unchanged: what a write stores; the read path objectql read path: a declared column the driver did not return stays an absent key, so mongo/memory rows are sparse where SQL rows are not #29408 landed; a predicate update's affected-row count; the read's exclusions (formula, unprovisioned injected, federated-unprovisioned, undeclared, FLS-refused stay absent).
  • Changeset .changeset/29538-write-answer-declared-column-null.md: @objectstack/objectql patch, both declaration lines.

Why

After #29408 every read answers an unwritten declared column null on every driver, but the write answer still came straight from the driver: driver-sql echoed null, driver-mongodb and driver-memory echoed only the stored keys. A create on mongo or memory answered { title: 'x' } and the very next GET answered { title: 'x', note: null }; a formula over note answered null (a fault) in the create response and its value on the GET. Ruling-ref 6584622486 (contract review on #29528, open_question 2 answered B: this follow-up).

Tests

  • New pins: objectql/src/write-answer-declared-columns.test.ts (sparse driver, 12 write paths toStrictEqual the next findOne, formula control; plus 4 cases for a driver that stores a column without echoing it: insert, batch per row, update with a prior stored value, PATCH clearing to null), runtime/src/declared-column-null-on-write.test.ts (driver-sql and a sparse stub with SecurityPlugin: FLS-refused column absent from the member's insert / batch / update answers, null for system), driver-mongodb/src/mongodb-declared-column-null-on-write.test.ts (fake Db; the single-row arms' collection declares only insertOne / updateOne / findOne, the batch arm's only insertMany / find).
  • Forced test edits (six assertions in four files, plus the hook reads in a fifth). Each read "left unset" off the write answer on a sparse stub; each now asserts the answer is null AND the stored row has no key. Per file, the production caller that reads the write answer for that key today:
    • engine-default-value-tokens.test.ts (owner, a lookup whose current_user default resolves to nothing): none. grep for .owner === undefined, .owner !== undefined, 'owner' in over packages/, examples/, ui/ non-test source: 0 hits. The readers of a write answer that do exist (record-change flow record, which materialises missing declared fields to null anyway; plugin-audit; the REST doors) treat absent and null alike.
    • engine-lookup-referential-integrity.test.ts (recorded_by, a readonly lookup stripped before the reference check): none. Test-local field; no production code reads a stripped field off a write answer as undefined (same grep shape, 0 hits).
    • engine-select-option-default.test.ts ×3 (status, tags): none. grep (record|row|result|created|data).(status|tags) (===|!==) undefined: one hit, examples/app-todo/src/objects/task.hook.ts:86, which reads data.status = ctx.input.data (the PATCH payload), not the write answer.
    • engine-write-formula-hydration.test.ts (the KEY SET of the createData record): none. grep Object.(keys|entries)((created|updated|inserted|saved|written)…): one hit, core/src/utils/import-runner.ts:799, which iterates the import's payload, not the answer; ui/: 0 hits. The assertion now also toStrictEquals the next read.
    • protocol-save-notice-answer.test.ts (say_after on ctx.result in after-hooks): none. Test-local field; grep ctx.result with undefined / in / hasOwn / Object.keys in production source: only runtime/src/sandbox/quickjs-runner.ts:672, which tests the whole ctx.result, not a key.
  • The one real consumer that relied on the old answer was not one of these six: trigger-record-change/src/multilookup-context.test.ts. It was fixed upstream in the engine (above), not by editing the test.
Ablations (each: mutation on disk, objectql rebuilt, marker proven in dist/, restore proven by blob == HEAD and the marker absent from dist/)
  • A1 insert fill removed → 14 objectql, 2 driver-mongodb and 2 runtime reds. A2 update fill removed → 11, 1 and 2. A3 insert fill moved after the security mask → the FLS pin reds on both runtime drivers. A4 the same for update → the FLS pin reds again. (A1–A4 measured by the previous dev on 1cb3f6a3, hand-off 6591637215.)
  • A5 storedColumnsLeftUnwritten made a no-op (fill everything) → 3 of the 4 new non-echo pins red + trigger-record-change multilookup-context red; the PATCH-clears-to-null pin stays green, as a control should.
  • A6 the write fill emptied → all 3 driver-mongodb arms red. (A first A6 attempt failed the DTS build and measured nothing; re-run with a typed mutation.)

Acceptance notes

  • Engine-level write results keep internal: true fields whole (the A-prime ruling), so an engine-level write answer still differs from an engine findOne there on every driver. Pre-existing, out of scope; the REST doors strip them.
  • Two gate reds on this PR's own new test code were instrument readings, fixed in the tests without touching any gate or ledger: check:engine-double-contract counted the mongo pin's native Collection fake as an engine double (find + insertMany are in ENGINE_SIBLINGS, insertMany in ENGINE_ONLY_MEMBERS), and check:where-matcher lifted the driver literal into the sparse fake's matcher because its refusal text said "sparse driver". Both are reported to the seat.

Verification

All on head 77b6b65dd (base origin/main merged at 143b91915), unless noted. Every heavy run went through scripts/pm/os-verify-lock.sh; each line quotes its VERDICT exit.

  • Touched packages: @objectstack/objectql test 545/545 files, 10792 passed; driver-mongodb 35 passed + 6 skipped files, 728 passed / 194 skipped; runtime 535/535 files, 8562 passed / 19 skipped; trigger-record-change 12/12, 121 passed. Typecheck (incl. check:test-typecheck) exit 0 for objectql, runtime, driver-mongodb.
  • node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack: 63 derived; each run, exit codes recorded; --ran → "63 derived famil(ies) accounted for — 63 run, 0 NOT-MEASURED". pnpm lint exit 0.
  • Dogfood battery on 94b04574b (the later commits are test-only edits and a merge of main): closure build exit 0; pnpm --filter @objectstack/dogfood test exit 0, 399 passed + 3 skipped files, 3297 passed / 28 skipped, 0 failed.
Downstream consumer sweep (prefix direction: packages whose tests boot a real ObjectQL), on f847848 after the engine fix, all exit 0

objectql 544 files · runtime · driver-mongodb · driver-memory · rest · plugin-security · plugin-audit · trigger-record-change 12/12 · metadata-protocol 308 + 4 skipped · service-analytics 208 · plugin-auth 215 · plugin-approvals 110 · service-messaging 63 · service-storage 71 · service-settings 67 · plugin-sharing 63 · service-automation 262 · service-datasource 70 · plugin-webhooks 25 · client 54 · organizations 18 · example-showcase 45 · plugin-email 48 · service-mailbox 10 · driver-odata 9 · plugin-pinyin-search 4 · connector-mcp 9 · cloud-connection 67 · example-crm 7 · cli 566 + 1 skipped.

The first pass (on 3597f584, before the carried-value rule) had 22 of those 23 sweep packages green and trigger-record-change red 1 (multilookup-context.test.ts: expected null to be 'ch_1'), which the engine change above fixes.

Human Minimum

  • 1. Schemas start as Zod; packages/spec holds no runtime logic. (No packages/spec change.)
  • 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 ## 维护者速读 block is the fleet's maintainer summary, in Chinese by convention.)
  • The Before Submitting list in CONTRIBUTING.md is done.

Generated by Claude Code

claude added 10 commits November 2, 2026 22:55
…r did not echo as null, as the next read does (#29538)

Claude-Session: https://claude.ai/code/session_01ADHkmQjPiXgNoSaEpqeGvY
Co-authored-by: Claude <noreply@anthropic.com>
…next read per path and driver; changeset (#29538)

Claude-Session: https://claude.ai/code/session_01ADHkmQjPiXgNoSaEpqeGvY
Co-authored-by: Claude <noreply@anthropic.com>
…nswer, which now answers the unstored column null (#29538)

Claude-Session: https://claude.ai/code/session_01ADHkmQjPiXgNoSaEpqeGvY
Co-authored-by: Claude <noreply@anthropic.com>
…alue for, so a driver that stores it without echoing it leaves it absent (#29538)

The fill now skips a column the insert payload, or the by-id PATCH and then
the stored row before it, holds a non-null value for. On driver-sql,
driver-memory and driver-mongodb the echo is the stored row, so nothing
changes there; on a driver that stores a column without echoing it, the
answer no longer claims a null the row does not hold, which a reader that
layers the payload under the answer (the record-change flow record) read
over the written value.

Claude-Session: https://claude.ai/code/session_01JQwxbXRug3R6WZAjtFuAoS
Co-Authored-By: Claude <noreply@anthropic.com>
…nly the native members its path calls; type the stored-row reads (#29538)

The single-row arms' Collection fake declares insertOne / updateOne / findOne,
the batch arm's declares insertMany / find (its next read is a find). The three
stored-row reads in the forced objectql test edits name their object in the
AST instead of erasing the options with `as any`.

Claude-Session: https://claude.ai/code/session_01JQwxbXRug3R6WZAjtFuAoS
Co-Authored-By: Claude <noreply@anthropic.com>
…literal, so the where-matcher lift judges it (#29538)

check:where-matcher reads identifiers out of the template text too, lifted the
`driver` literal and its `name` shorthand with the matcher, and filed it
unjudged. The matcher refuses every operator it does not implement; it now
reads CONFORMING.

Claude-Session: https://claude.ai/code/session_01JQwxbXRug3R6WZAjtFuAoS
Co-Authored-By: Claude <noreply@anthropic.com>
…needs neither an object key nor an `as any` (#29538)

Claude-Session: https://claude.ai/code/session_01JQwxbXRug3R6WZAjtFuAoS
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 3, 2026
@github-actions

github-actions Bot commented Nov 3, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

1 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
  • 2 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 61 of 219 client-bound route-ledger rows — the other 158 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 158: 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; 103 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 — 21 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 589049b023d82c94508bf9fce752ea19069279e5 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 507d47784404af97242daf7a2feb818c9bd7354e — the merge of head 77b6b65dd2a9a51b058f8daba8f18e42766fbe2f into base 589049b023d82c94508bf9fce752ea19069279e5, 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 507d47784404af97242daf7a2feb818c9bd7354e && git checkout 507d47784404af97242daf7a2feb818c9bd7354e
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 589049b023d82c94508bf9fce752ea19069279e5 77b6b65dd2a9a51b058f8daba8f18e42766fbe2f && git checkout -B drift-repro 589049b023d82c94508bf9fce752ea19069279e5 && git merge --no-ff 77b6b65dd2a9a51b058f8daba8f18e42766fbe2f

node scripts/docs-audit/affected-docs.mjs --json 589049b023d82c94508bf9fce752ea19069279e5

⚠️ 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#5 · os-warren · session_01JQwxbZUR3xvHyDPCjNL5P4

席位核对

ACCEPT · domain:server#5 · head 77b6b65d · 2026-11-03T08:41Z · This is the seat's review of record at TIER_DEFAULT. No contract-review face applies: record-recognisers finds no hits on the paths, and the changeset declares Clause-②: no. Not governed: check-governed-merges --pr 29858 reads 0 of 11 paths, +739/-13.

  1. Shape: draft, base main, body Fixes #29538. 11 files:
    • storedColumnsLeftUnwritten in declared-read-columns.ts, and its two call sites in engine.ts: the insert result (single and batch, paired with liveRows by index) and the by-id update readback.
    • Three new test files (objectql unit, runtime doors, driver-mongodb native-fake pins).
    • Six forced objectql test edits and a patch changeset.
  2. Premise measured: it holds. After objectql read path: a declared column the driver did not return stays an absent key, so mongo/memory rows are sparse where SQL rows are not #29408 the next read answers an unwritten declared column null on every driver, but the write answer came straight from driver-mongodb and driver-memory without that key. The seat read the core diff:
    • The fill selects the whole stored declared set, minus every column the payload, or the PATCH and then the prior row, carries a non-null value for. A stored value is therefore never answered null. This is the carried-value rule, which the trigger-record-change consumer red, multilookup [P2] record-change context does not hydrate multi-lookup (junction) fields #1872, measured.
    • The fill runs before formulas and hooks and inside the verb, so the FLS mask still deletes refused keys.
    • Ablation A5 reds 3 pins plus the trigger test, and A6 reds the 3 mongo arms. Both were restored.
  3. Gates on this head:
    • CI check-runs pinned to 77b6b65d: 48 total, 35 success, 13 skipped, 0 failed.
    • Dev, local on the head: objectql 545/545 files, driver-mongodb, runtime and trigger-record-change green; typecheck ×3 exit 0; 63 derived gates with --ran 63 run and 0 NOT MEASURED; pnpm lint exit 0.
    • Dev, dogfood battery on 94b04574, the last code change: 399 files, 0 failed.
  4. Accepted deviations:
    • The carried-value rule goes beyond the card's literal wording. A null the row does not hold would break a reader that layers payload under answer.
    • trigger-record-change was added to the sweep.
    • The six forced objectql edits are each proven, by grep, against any production reader that tells a missing key from null on those keys.
    • Seat 1's test files were restructured into per-arm minimal native Mongo fakes, with no spreads.
  5. Out of scope:
  6. Landing: not governed and under 3000 lines, so the seat lands it with pr_ready + automerge_enable. At MERGED: a landing record on objectql write path: an insert or update response leaves a declared column the driver did not echo as an absent key on mongo/memory, while the next read answers it null (#29408) #29538 (closed by Fixes), and pm:dispatched comes off.

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review November 3, 2026 08:42
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Nov 3, 2026
Merged via the queue into main with commit dbe2a93 Nov 3, 2026
49 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/project-thread-2polz0 branch November 3, 2026 09:07
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

1 participant