fix(objectql)!: engine.find/findOne refuse an unmaterializable formula ORDER BY (#7095) - #7337
Conversation
…rmula ORDER BY (#7095) #6994 closed the SORT axis at the REST ingress (`assertSortFieldsExist`, `400 INVALID_SORT`), covering everything reaching `findData`. A caller reaching `engine.find()` / `engine.findOne()` directly passed through none of it, and a `formula` ORDER BY there was dropped in silence. Measured on this change's base, real `ObjectQL` over a driver that really sorts: engine.find(o, { orderBy: [{ field: <formula>, order: 'asc' }] }) -> C A E B D engine.find(o, { orderBy: [{ field: <formula>, order: 'desc' }] }) -> C A E B D asc === desc (byte-identical) Ruled 2026-08-10 on #7095: refuse at the public boundary with guidance prose, never a silent drop. `assertOrderByIsMaterializable` refuses on both entry points with the same `400 INVALID_SORT` and the same remedy sentence the two ingress verdicts emit — pinned as an equality across all three doors, since separate wordings is how #4256 and #6673 drifted apart. The tolerance was to survive only behind a pinned internal path, and only if a MEASURED internal call site relied on it. The sweep found none: every hardcoded internal sort names a real stored column, and no shipped object declares a `formula` field. So no internal path shipped, and a negative pin keeps one off the public options shape. The one author-reachable consumer is why ingress-only was not tenable: a saved report's `query.orderBy` is forwarded verbatim into `engine.find` by `plugin-reports`. One path deliberately does NOT become a refusal — a nested `expand` sort raises it inside `expandRelatedRecords`, whose pre-existing graceful-degradation catch swallows every expand failure, so that path moves from silent to observable (a warning naming the field and the fix) rather than refusing. Reversing that backstop is #3821's decision, not this card's; it is measured and pinned as-is. The ingress gate is untouched, and the engine door judges only the third verdict — unknown and dotted names still reach the driver from a direct call, because refusing those is a posture change on two further axes. Registered in the ADR-0087 step-17 ledger as `engine-find-formula-order-by-refused`; artifacts regenerated. Refs #7095, #6994, #6924, #4226, #4256, #3821, ADR-0087, ADR-0112 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HJgGahRqaYPRJ2oKmk9Czc
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
📓 Docs Drift CheckThis PR changes 3 package(s): 109 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
⛔ 7 release-owned page(s) also reference the affected code. These are read-only:
|
Gates run locallyEnumerated with the prescribed command: 64 in
All green (exit 0):
Plus Pre-existing red, NOT caused by this PR:
Tests
Generated by Claude Code |
|
PM verification — the ESLint red IS this PR's to fix, and the ratchet's own message prescribes the remedy. The session status said "hook flagged upstream commits, not my work". Measured: ESLint is green on
This PR adds four query-option call sites in test code (the refusal pins and the four opt-out-flag probes are exactly the shape the ratchet anticipates). The fix is the ratchet's second branch: spell the deliberately off-contract inputs Everything else about this PR reads well — the four-plausible-flags negative pin, the word-for-word three-door prose equality pin with the layering argument for duplication, and a complete ADR-0087 trio following Generated by Claude Code |
… them to `any` The four call sites the #7095 pins added tripped the #4918 query-options-erasure ratchet (test surface 249 -> 253). Fixed at the call sites, per the rule's own prescription — the ceiling is unchanged and no pin is weakened. Three were ON-contract and are now typed: - the `it.each` sort table is `Array<[string, NonNullable<EngineQueryOptions['orderBy']>]>`, so the three refused sorts are checked as the well-formed `SortNode[]` they are. It is the FIELD they name that the engine refuses, never their shape, and an `as any` there would have erased the one channel that enforces `{ field, order }` on a direct engine call — the `direction`-vs-`order` mistake #4674 is about. - both `expand` sites drop the assertion entirely: `EngineQueryOptions.expand` is `Record<string, QuerySchema>`, so the nested `{ orderBy }` was always assignable and the cast was never buying anything. One is DELIBERATELY off-contract — the negative pin that smuggles an opt-out flag onto the public options bag — and is now `as unknown as EngineQueryOptions` rather than a bare `as any`: it names the contract being bypassed, keeps the rest of the call type-checked, and greps as an intentional act. That is exactly the case #4918 carved the spelling out for, since the assertion's whole subject is that the engine rejects the unknown key. Refs #7095, #4918, #4674, #4721 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HJgGahRqaYPRJ2oKmk9Czc
Ratchet fixed at the call sites — and a correction to my earlier gate reportThe correction firstMy earlier "all gates green" comment was not trustworthy, and the fault was mine. Two of the three batches I ran captured the exit code through a pipeline: out=$(timeout 600 pnpm $g 2>&1 | tail -3); code=$? # ← $? is tail's status, always 0So every gate in those batches reported Everything has now been re-run with the exit code captured directly ( The fix — ceiling unchanged, no pin weakenedFour sites, fixed per the rule's own prescription. Three were on-contract and are now typed:
One is deliberately off-contract — the negative pin that smuggles an opt-out flag onto the public options bag — and is now Budget number untouched; no pin altered. All 118 conformance tests still pass, Corrected gate results (exit codes captured directly)
One pre-existing red, re-confirmed against the current
CI is running on Generated by Claude Code |
⛔ merge queue 构建失败 — 先分诊,再决定要不要重排队列构建 31376073539 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集), 失败的 job(日志抽取,best effort):
历史信号:
分诊清单:
Generated by Claude Code · merge-queue-triage workflow (#4859) |
Closes #7095. Follows #6994 (the ingress half) and #6924 (the sort-hint prescription).
The defect, reproduced on current
mainfirst#6994 refuses a non-dotted
orderBynaming aformulafield at ingress (assertSortFieldsExist,400 INVALID_SORT), which covers everything reachingfindData: the REST list route,POST /data/:object/query, the export route and the RPC dispatcher. It could not cover a caller reachingengine.find()/engine.findOne()directly.Reproduced on
06be54ec3before touching anything — theRECORD OF A KNOWN HOLEpin #6994 left behind still passed, i.e. the hole was still open:A
formulavalue is computed on read, so no driver materialises a column for it: the ORDER BY reached the driver, found nothing, and the unknown-column backstop returned the rows unordered under a success — carrying the very values they were asked to be ordered by. Withlimit, "the latest N" was an arbitrary N nothing in the response could reveal.The ruled direction
Per the maintainer ruling (2026-08-10 on #7095): refuse at the public boundary with guidance prose, never a silent drop.
assertOrderByIsMaterializablerefuses on both entry points with the same400 INVALID_SORTand the same remedy sentence the two ingress verdicts emit.findOneis covered as well asfind— same public boundary, and the stakes are higher there:findOneapplieslimit: 1, so a dropped sort does not merely reorder the answer, it returns a different record that looks just as legitimate.The measured verdict on internal reliance
The ruling made the internal-caller tolerance conditional on a measured internal call site relying on it. The sweep found none, so outcome 1 applies: refuse everywhere, no internal path shipped.
Sweep (151 non-test
orderByoccurrences outsidespec/metadata-protocol, narrowed to those reaching the engine directly — drivers are the receiving end,client/client-react/MCP go over the wire through ingress):plugin-approvals/approval-service.ts(×4)created_atplugin-reports/report-service.tsupdated_at,next_run_at,q.orderByplugin-sharing(×4)created_at,nameservice-queue/db-queue-adapter.tscreated_at,priority,scheduled_forservice-job/db-job-adapter.tsstarted_atservice-messaging/messaging-service.tscreated_atplugin-email/outbox-sweep.tscreated_atmetadata/loaders/database-loader.tsrecorded_at,versionmetadata/utils/history-cleanup.tsversionruntime/domains/share-links.tscreated_atplugin-auth/objectql-adapter.ts(×2)sortBytypes/keyset-walk.tskey(defaultsid)engine.expandRelatedRecordsexpandsortTwo independent facts make the verdict solid rather than a shrug:
formulafield at all — the only non-test occurrences are the spec's own field builder (field.zod.ts) and one FLS contract fixture. So no hardcoded internal sort could name one.No test relied on the drop either, except the deliberate
RECORD OF A KNOWN HOLEpin — which said in as many words that it should go red the day this landed. It is now inverted in place.The dynamic sites do not rely on the tolerance; they are the paths through which the silent drop was author-reachable, which is exactly why ingress-only was not tenable: a saved report's
query.orderByis forwarded verbatim intoengine.findbyplugin-reports, bypassing the ingress gate entirely.One path deliberately does NOT become a refusal⚠️
A nested
expandsort raises this refusal insideexpandRelatedRecords— but that sub-read sits inside a pre-existing graceful-degradationcatch("if expand fails, keep original IDs") which swallows every expand failure. Measured:So that path moves from silent to observable (a warning naming the field and the fix) rather than refusing. Reversing that backstop is the #3821-family swallow — a decision about all expand failure modes, not a rider on this card. It is measured and pinned as-is rather than left implied; flagged for follow-up.
Scope held deliberately narrow
unknown>dotted> unmaterializable precedence, sameparamname the engine cannot know. Not weakened.summary/rollup fields are unaffected and still sort in both directions — they get a real maintained column. A control pins that, so widening the set to the spec'sCOMPUTED_VALUE_TYPES(the write contract) goes red.SELECT *tolerance, are untouched — pinned.driver.findhas already appliedlimit/offset, so sorting afterapplyFormulaPlanwould reorder an arbitrary page.Docblocks updated honestly
assertProjectionFieldsExist's documented internal-caller tolerance is the posture this narrows, so it now says the tolerance is per-axis and that the sort axis no longer has it — and that nothing equivalent has been measured for the projection axis, so the sentence is not a licence to assume it.assertSortFieldsExist's SCOPE paragraph no longer claims the direct path "still gets the silent drop".Pins
engine.findrefuses — asc, desc, and as the second of two sort keys — with assertedstatus+code+field+object, not merely "throws".engine.findOnerefuses, with awherepresent so it is the sort verdict and notrequireFindOnePredicateanswering first.summaryfield still sort throughengine.find; a formula field is still selectable and still computed.orderBynaming aformulafield answers 200 in arbitrary order — the sort is silently dropped (measured on driver-sql + driver-memory) #6994 are untouched and still green.Why the prose is duplicated rather than imported
metadata-protocolis assembled from an engine (assembleMetadataProtocol), so it is the layer aboveobjectql; importing its error helper intoengine.tswould invert the layering. The word-for-word equality pin is what keeps the duplication honest.Changeset / ADR-0087
Declared
majorfor@objectstack/objectql— a public API throws where it used to succeed. That triggers the ADR-0087 disposition requirement, so the trio is filled in following #7210 (708431313) as the worked example:.changeset/engine-find-formula-orderby-refusal.mdwith<!-- adr-0087: registered engine-find-formula-order-by-refused -->packages/spec/src/migrations/registry.ts— step-17semanticentrypackages/spec/spec-changes.json+docs/protocol-upgrade-guide.mdregenerated (gen:spec-changes,gen:upgrade-guide); both--checkgates greenregisteredrather than one of the threenot-requiredforms:@objectstack/objectqlis published, no prior id covers this surface, and the change ships rewrite instructions — sono-migration-prescriptionwould be self-contradictory.packages/specis touched for the ADR-0087 disposition (ledger + generated artifacts only, no schema change) — cross-seat declaration per #6017.Refs #7095, #6994, #6924, #4226, #4256, #3821, #7210, ADR-0087, ADR-0112
Generated by Claude Code