Repository navigation
fix(objectql)!: an unprojected read serves the declared fields, never an orphaned column - #21612
Conversation
Env-gated recorder (OS_PROBE_21571_OUT) at the engine's read sites and the by-id update result. Reverted before the fix lands. Claude-Session: https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi Co-authored-by: Claude <noreply@anthropic.com>
…projected-read-declared-fields
The probe (920cecf) recorded which in-process readers receive row keys the object does not declare; its readings are in the PR body. It leaves the tree here, so the net diff carries none of it. Claude-Session: https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi Co-authored-by: Claude <noreply@anthropic.com>
…an orphaned column
find/findOne shape the rows the driver returns to the object's declared
field map plus the platform-provisioned columns, so a column no metadata
declares (a field retired in an upgrade) no longer rides back on any door,
including driver-sql's select('*') recovery rung. One list of provisioned
columns now serves the default projection, the explicit-projection filter
and the write door.
Claude-Session: https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi
Co-authored-by: Claude <noreply@anthropic.com>
…the default projection Claude-Session: https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi Co-authored-by: Claude <noreply@anthropic.com>
…projected-read-declared-fields
…projected-read-declared-fields
📓 Docs Drift CheckThis PR changes 1 package(s): 35 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 11 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 17 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 4bb19c524028050cefaed2cabb52b9df3e407220 && git checkout 4bb19c524028050cefaed2cabb52b9df3e407220
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 9a4182a752fd53b24a14bbcd2e1b4270e1174e7a 4bc22fe798a9c0e0d04c35dcc9cfe3e76f03b383 && git checkout -B drift-repro 9a4182a752fd53b24a14bbcd2e1b4270e1174e7a && git merge --no-ff 4bc22fe798a9c0e0d04c35dcc9cfe3e76f03b383
node scripts/docs-audit/affected-docs.mjs --json 9a4182a752fd53b24a14bbcd2e1b4270e1174e7a
|
Fixes #21571
Clause-②: no (narrowing)
An unprojected read now serves the object's declared fields plus the platform's system columns. A column no metadata declares, such as a field retired in an upgrade whose column additive sync leaves behind, no longer leaves the engine. The decision is made once, in the engine (
packages/objectql), so every driver and every door gets the same answer. No driver source and nometadata-protocolsource is edited.Measured first (before any fix)
The probe was a temporary, env-gated recorder at the engine's read sites (commit 920cecf, reverted by a7500b3; the net diff carries none of it). In trim mode it deleted every undeclared key at
find,findOne, the by-id update result and the update/delete prior reads, a superset of this fix.sys_accountrow)The 8 objectql failures, by cause:
engine.test.ts(平台形态的迁移门禁:带自检的数据迁移 + 部署级标记 —— file-as-reference 回收与 strict 翻转都依赖它 #3617): thesys_migrationdouble declared onlyid. The production object declaresblocking,verified_atandlast_run_at, andreadMigrationFlagVerifiedreads those. The fixture now declares what production declares.engine.test.ts/plugin.integration.test.ts: red only at the prior-read probe sites (a fixture'sreadonlyWhenreads an undeclaredlocked). This fix does not touch prior reads.driver-fault-boundary-redaction.test.ts: the mock driver hands back live references to its store, and an in-place delete corrupted it. So the fix never mutates a driver row: a row carrying an undeclared key is copied without it.In-process readers of undeclared columns, by call site:
os migrate plan'sunmapped_columndetection introspects the table through the driver (driver-sql/src/schema-drift.ts). Unaffected.os migrate account-issuerreadssys_account.issuerthrough the driver (cli/src/commands/migrate/account-issuer.ts). Unaffected.os migrate applyaccount preflight (cli/src/commands/migrate/apply.ts, then plugin-authaccount-identity-preflight.ts) callsengine.findwith an explicit projection naming the retiredissuer. The engine's unknown-plain filter already drops that name today. Unchanged by this PR (see Acceptance notes).packages/metadata/src/migrations/*) use driver raw DDL. Unaffected.cloneDatacopies every key of itsfindOnesource into an insert. Before this fix, an orphaned column made the clone fail at the write door (Unknown field 'mailing_street',INVALID_FIELD); it now succeeds. This reader was hurt by the orphaned column, it did not rely on it.getDataandfindDataall read throughfind/findOne. They narrow with the door, which is intended.scripts/backfill-contact-mailing-address.tsis external and reads through REST. The changeset names its interim route.Hypothesis verdicts:
findreached the driver withfieldsundefined, and driver-sql answered withselect('*').PLATFORM_PROVISIONED_COLUMNS. That list moved intodeclared-read-columns.ts. The explicit-projection filter (find/findOne), the new default projection and the write door'sundeclaredWriteFieldErrorsall read it. No second list.expand, file references and the hooks. Internal omission, credential masking and the__searchstrip still run after the hooks. The conformance matrix pins formula, password mask,internal, and each system column.findandfindOnereturn row bodies.countreturns a number.aggregatereturns aggregates, and itsgroupBynames are gated at the door.findStreamwas retired in 17.0.expandre-entersthis.find.Changes
packages/objectql/src/declared-read-columns.ts(new):PLATFORM_PROVISIONED_COLUMNS,declaredColumnSet(no opinion for an absent, array or empty field map, the rule the read and write doors already share), and the non-mutatingwithDeclaredColumnsOnly/rowsWithDeclaredColumnsOnly.packages/objectql/src/engine.ts:findandfindOneshape the driver's rows right after the driver call. The explicit filter and the write door read the shared list.packages/objectql/src/no-operator-object-door.ts: a comment pointer to the list's new home.packages/rest/src/data-query-unprojected-declared-fields.test.ts(the reach pin),packages/objectql/src/unprojected-read-declared-fields-conformance.test.ts(the conformance matrix), and thesys_migrationfixture inengine.test.ts..changeset/21571-unprojected-read-declared-fields.md:@objectstack/objectqlminor, BREAKING narrowing, ADR-0087not-required (no-migration-prescription), and the interim route.content/docs/data-modeling/queries.mdx: one paragraph stating the default projection.Pins
RestServer, thenObjectStackProtocolImplementation, thenObjectQL, then a realSqlDriveron better-sqlite3 with a file database. Boot one declares two mailing fields and writes values. Boot two retires them, the card's upgrade. ThenPOST /api/v1/data/rq_contact/querywith nofields.GET /data/:object/:id, every-key-declared was red, and the ladder rung was red. The system-columns pin and the explicitINVALID_FIELD/ 400 pin were green.objectql, three driver behaviours (whole row,projection,ladder) × doors (find, barefind,findOne, ladder projection, retired-only projection,expand,findData,getData,cloneData), plus declared-treatment and store-not-mutated pins. A future driver shape is one entry.INVALID_FIELD/ 400. The test assertscodeandstatus.git grepover tests for orphaned, unmapped, retired or undeclared column readings, plus the trim-mode runs above: no test asserted that an unprojected read returns an undeclared column. The only fallout was thesys_migrationfixture, which now declares its columns. No refusal assertion was changed.Reverse verification (fix committed first, at 9593fbd)
Each shaping call was replaced in turn with
node scripts/ablation-replace.mjs(anchor hit 1 to 0, blob changed). Then objectql was rebuilt (exit 0), andablation-dist-preflight --absentconfirmed the call is absent from all 14 built files.findcall removed: reach pin 3 failed, 3 passed (query, every-key-declared, ladder). Matrix 17 failed, 19 passed.findOnecall removed: reach pin 1 failed, 5 passed (GET by id). Matrix 9 failed, 27 passed (findOne,getData,cloneData× 3 shapes). The clone's failure readsUnknown field 'mailing_street' on object 'rq_contact'.git checkout HEAD -- packages/objectql/src/engine.ts. Blob aadf4018 equals the HEAD blob,git diff HEADis empty andgit status --porcelainis clean. After a rebuild, the preflight finds both calls present in dist. Reach pin 6/6 and matrix 36/36.Local verification, at 4bc22fe (the PR head, after merging main, which includes #21545)
pnpm --filter @objectstack/objectql test: 367 files, 7415 passed.test:repo: 1 file, 5 passed.typecheck(tsc plus test-typecheck): exit 0.pnpm --filter @objectstack/rest run typecheck: exit 0.restnorobjectql, so that run was not repeated.node scripts/pm/dispatch-gates.mjs --commands(no paths) re-derived the same 96 commands at this head. All 96 were run and reconciled with--ran(96 run, 0 unrun).node scripts/check-engine-split-ratio.mjs --days 90answered exit 2: NOT MEASURED. The clone is shallow inside the 90-day window, and this is a report-only metric.eslint --no-inline-config --format json. eslint reports the.md/.mdxpair as "File ignored because no matching configuration was supplied".eslint.config.mjsenables no type-aware linting (noparserOptions.project, no typed rules), so this diff cannot move an untouched file's verdict.@objectstack/objectql. The trim-mode measurement above covered rest, runtime, plugin-auth, plugin-sharing, plugin-audit and service-automation under a superset of this change.Acceptance notes
PATCH /api/v1/data/rq_contact/c1with{ name }answers 200, and itsrecordcarriesmailing_street: "1 Retired Way".select *), andupdateDatastrips onlyinternalfields from it.returning('*')too. That is not measured here; on a new row the orphaned columns would read null.previousrows handed to hooks) still carry orphaned columns. In process only, no public door measured. Carrier: none.engine.aggregatedoes not refuse an undeclaredgroupByname. The data door does (assertGroupByFieldsExist). Carrier: none.os migrate applyaccount preflight'sissuerslabel reads(none)on the engine path, because the engine already drops the retiredissuerfrom an explicit projection. The collision verdict keys on provider and account ids and is unaffected.os migrate account-issuerreads through the driver and shows real issuers. Carrier: none.--allow-destructivedrops them (the coupling #21571 names) #21573 (the operator-onlyos migrateread of unmapped columns) is not addressed here. It is the interim route the changeset names.Generated by Claude Code