Repository navigation
fix(objectql): a write answers every stored declared column the driver did not echo as null, as the next read does (#29538) - #29858
Conversation
…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>
#29538) 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>
📓 Docs Drift Check1 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
Coarse fallback — 21 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # 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 |
|
domain:server#5 · os-warren · session_01JQwxbZUR3xvHyDPCjNL5P4 席位核对ACCEPT ·
|
Requested by Warren · project thread
Fixes #29538
Clause-②: no
Metadata-protocol: none
维护者速读
null,和紧接着的一次读取完全一致;本次写入自己带了值(或更新前已存值)的列不补null,驱动没回显就照旧缺省。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
ObjectQL.insert(single, batch, and eachinsertManyoutcome) and the by-idupdatereadback now answer every stored declared column the driver did not echo asnull, 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'sselectedStoredColumns+withSelectedColumnsAnswered(no copy). Placed right after the existingwithDeclaredColumnsOnlyshaping, before the formulas and the after-hooks, so a formula over an unwritten column evaluates againstnullas 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,batchDatacreate / update / both upsert arms.nullthe row does not hold (new in this round,storedColumnsLeftUnwrittenindeclared-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 PATCHnullstays 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 fabricatednullthere overwrote a written multi-lookup value (multilookup-context.test.tsred, below)..changeset/29538-write-answer-declared-column-null.md:@objectstack/objectqlpatch, both declaration lines.Why
After #29408 every read answers an unwritten declared column
nullon every driver, but the write answer still came straight from the driver: driver-sql echoednull, driver-mongodb and driver-memory echoed only the stored keys. A create on mongo or memory answered{ title: 'x' }and the very nextGETanswered{ title: 'x', note: null }; a formula overnoteanswerednull(a fault) in the create response and its value on theGET. Ruling-ref 6584622486 (contract review on #29528, open_question 2 answered B: this follow-up).Tests
objectql/src/write-answer-declared-columns.test.ts(sparse driver, 12 write pathstoStrictEqualthe nextfindOne, 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 tonull),runtime/src/declared-column-null-on-write.test.ts(driver-sql and a sparse stub withSecurityPlugin: FLS-refused column absent from the member's insert / batch / update answers,nullfor system),driver-mongodb/src/mongodb-declared-column-null-on-write.test.ts(fakeDb; the single-row arms' collection declares onlyinsertOne/updateOne/findOne, the batch arm's onlyinsertMany/find).nullAND 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 whosecurrent_userdefault resolves to nothing): none.grepfor.owner === undefined,.owner !== undefined,'owner' inoverpackages/,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 tonullanyway; plugin-audit; the REST doors) treat absent andnullalike.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 asundefined(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 readsdata.status=ctx.input.data(the PATCH payload), not the write answer.engine-write-formula-hydration.test.ts(the KEY SET of thecreateDatarecord): 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 alsotoStrictEquals the next read.protocol-save-notice-answer.test.ts(say_afteronctx.resultin after-hooks): none. Test-local field;grep ctx.resultwithundefined/in/hasOwn/Object.keysin production source: onlyruntime/src/sandbox/quickjs-runner.ts:672, which tests the wholectx.result, not a key.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/)
1cb3f6a3, hand-off 6591637215.)storedColumnsLeftUnwrittenmade a no-op (fill everything) → 3 of the 4 new non-echo pins red +trigger-record-changemultilookup-contextred; the PATCH-clears-to-nullpin stays green, as a control should.Acceptance notes
internal: truefields whole (the A-prime ruling), so an engine-level write answer still differs from an enginefindOnethere on every driver. Pre-existing, out of scope; the REST doors strip them.check:engine-double-contractcounted the mongo pin's nativeCollectionfake as an engine double (find+insertManyare inENGINE_SIBLINGS,insertManyinENGINE_ONLY_MEMBERS), andcheck:where-matcherlifted thedriverliteral into the sparse fake's matcher because its refusal text said "sparse driver". Both are reported to the seat.Verification
All on head
77b6b65dd(baseorigin/mainmerged at143b91915), unless noted. Every heavy run went throughscripts/pm/os-verify-lock.sh; each line quotes itsVERDICTexit.@objectstack/objectqltest 545/545 files, 10792 passed;driver-mongodb35 passed + 6 skipped files, 728 passed / 194 skipped;runtime535/535 files, 8562 passed / 19 skipped;trigger-record-change12/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 lintexit 0.94b04574b(the later commits are test-only edits and a merge ofmain): closure build exit 0;pnpm --filter @objectstack/dogfood testexit 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 andtrigger-record-changered 1 (multilookup-context.test.ts:expected null to be 'ch_1'), which the engine change above fixes.Human Minimum
packages/specholds no runtime logic. (Nopackages/specchange.)pnpm lintpasses.main, one pull request.packages/spectouched: n/a.content/docs/references/,packages/*/CHANGELOG.mdorcontent/docs/releases/.## 维护者速读block is the fleet's maintainer summary, in Chinese by convention.)Generated by Claude Code