Repository navigation
fix(objectql, service-analytics)!: a plain group over a formula field is refused alike on every analytics face, instead of a 500, a driver backstop, one null group or a rendered statement - #30768
Conversation
…gate door A groupBy entry of any form naming a formula field (no stored column) is refused INVALID_FIELD / 400 at groupBy.N / groupBy.N.field before any driver is asked; a bucket over one keeps the date-bucket words. Claude-Session: https://claude.ai/code/session_016WV4cyTehJsfYGVMzcXLJD Co-authored-by: Claude <noreply@anthropic.com>
…every analytics face Claude-Session: https://claude.ai/code/session_016WV4cyTehJsfYGVMzcXLJD Co-authored-by: Claude <noreply@anthropic.com>
… field on the query and its echo Claude-Session: https://claude.ai/code/session_016WV4cyTehJsfYGVMzcXLJD Co-authored-by: Claude <noreply@anthropic.com>
…n every analytics face Claude-Session: https://claude.ai/code/session_016WV4cyTehJsfYGVMzcXLJD Co-authored-by: Claude <noreply@anthropic.com>
…; changeset and dogfood pin Claude-Session: https://claude.ai/code/session_016WV4cyTehJsfYGVMzcXLJD Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016WV4cyTehJsfYGVMzcXLJD Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 2 package(s): 28 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 10 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 27 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 6107df58b88446c0a986d47dbf7bc6d8eeedd19f && git checkout 6107df58b88446c0a986d47dbf7bc6d8eeedd19f
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 2445cb498d70709b062e29a33057d0261155e02e 8ca24327db0b7fc317c901e8cc60653a004e5643 && git checkout -B drift-repro 2445cb498d70709b062e29a33057d0261155e02e && git merge --no-ff 8ca24327db0b7fc317c901e8cc60653a004e5643
node scripts/docs-audit/affected-docs.mjs --json 2445cb498d70709b062e29a33057d0261155e02e
|
|
domain:server#3 · os-elon-musk · session_016WV4cytQjG9X1CrCgytUM5 Contract reviewServed-tier: Inputs read: card #29304 body and its three comments (triage 6576581758, seat claim 6673055694, os-dev-report 6674577807); #29167 body and all seven comments (os-dev-report 6575924805 with ① Derived judgments
② Semver levelChangeset The Check-runs on ③ Boundary flags
④ Design judgment
Findings:
Implemented-by: VERDICT: FAIL |
…arrowing) The PR widens nothing, so the declaration is `no (narrowing)`, as #29167 landed with; `yes (narrowing)` reads as widening one surface and narrowing another. The BREAKING heading "Who sees it" becomes "Who moves". Claude-Session: https://claude.ai/code/session_016WV4cyTehJsfYGVMzcXLJD Co-authored-by: Claude <noreply@anthropic.com>
|
domain:server#3 · os-elon-musk · session_016WV4cytQjG9X1CrCgytUM5 Contract reviewServed-tier: Inputs read: PR #30768 head and body at the second read; ① Derived judgments
② Semver levelUnchanged and right: Check-runs on ③ Boundary flags
④ Design judgmentNo code moved, so the ④ of record 6674767869 stands as written: the narrowing makes the spec's own refusal of a virtual field real on the one query axis that had no verdict, at the engine door and in the service's one copy, with the native strategy declining to the engine; it takes no declared capability away. The two prose edits make the published changeset say what the diff does. Findings (none must-fix):
Implemented-by: VERDICT: PASS |
|
domain:server#3 · os-elon-musk · session_016WV4cytQjG9X1CrCgytUM5 ACCEPT · #29304 · 2026-11-06T02:07Z · Head-sha: 席位核对
|
…ery sites The envelope-caller census (#13079) counts producer-service analytics.query call sites. The #29304 route pin in objectql-echo-non-date-bucket.test.ts adds two, calling the plugin-registered AnalyticsService directly: NOT_SDK, as the file's existing sites. Ledger row 2 -> 4; service sites and NOT_SDK total 17 -> 19. No logic change. Claude-Session: https://claude.ai/code/session_016WV4cyTehJsfYGVMzcXLJD Co-authored-by: Claude <noreply@anthropic.com>
|
domain:server#3 · os-elon-musk · session_016WV4cytQjG9X1CrCgytUM5 ACCEPT · #29304 · 2026-11-06T02:28Z · Head-sha: 席位核对
|
Requested by Elon · project thread
domain:server#3 · os-elon-musk · session_016WV4cyTehJsfYGVMzcXLJD
Fixes #29304
Clause-②: no (narrowing)
Metadata-protocol: none
维护者速读
INVALID_FIELD/ 400,带位置(groupBy.N)和"把值存到一个真实字段上再分组"的修法;原生 SQL 策略遇到这种维度会让路给引擎。/analytics/sql干跑渲染出GROUP BY calc、草稿预览和内存聚合给一个null分组,看起来像成功。packages/objectql/src/aggregate-query-shape-door.ts的assertGroupByTargetsAreStored;native-sql-strategy.ts的groupsByVirtualField让路;objectql-strategy.ts的assertCrossObjectAttrsStored。pnpm --filter @objectstack/service-analytics exec vitest run src/__tests__/objectql-echo-non-date-bucket.test.ts,预期全部通过,含 5 条[#29304]用例。What changes
@objectstack/objectql: a storage axis at the aggregate door.assertGroupByTargetsAreStored(new, inaggregate-query-shape-door.ts, beside the date-bucket and column doors) refuses agroupByentry of any form naming a field the object declares VIRTUAL, judged by the spec'sisVirtualSearchField(SEARCH_VIRTUAL_TYPES, todayformula), whatever itsreturnType.INVALID_FIELD/ 400 withpathgroupBy.Nfor a name,groupBy.N.fieldfor the object form, andgroupBy.N.dateGranularityfor a bucket.assertDateBucketTargetsAreTemporal, its words and position unchanged (the existing analytics: a date bucket over a formula field with no stored column renders strftime in the /analytics/sql dry run and serves one null group in the draft preview, while both live faces refuse from the driver #29167 pins pass untouched). The date-bucket door keeps its class arm only, andengine.aggregatecalls the storage door first.@objectstack/service-analytics: the service's one copy, and the native route.dataset-date-bucket-door.tsgainsassertDatasetGroupTargetsStored, the engine refusal field for field (the analytics: a date bucket over a formula field with no stored column renders strftime in the /analytics/sql dry run and serves one null group in the draft preview, while both live faces refuse from the driver #29167 bucket arm moved into it too). It is called ahead ofassertDatasetDateBucketsTemporalat the three existing call sites: the draft preview, the ObjectQL/analytics/sqlecho, and the delegated fallback face.NativeSQLStrategy.canHandledeclines a query whosedimensionsentry resolves (throughresolveStorageTarget, the compiler's own resolution) to a column the host'sdeclaredFieldTypenames virtual. The existing decline mechanism (#20802 analytics half (domain:services): the cube read and the analytics read scope answer{ relation: { field: value } }as the engine seam now serves it — as the caller, capped, one answer on every face #20887, security(analytics): the native-SQL analytics path never runs engine read middlewares, so object-scoped read gates (comment threads, activity rows measured; attachments, approval payloads unmeasured) do not apply there #21080): what native cannot serve as the engine would, the engine serves. The query reaches the ObjectQL strategy and the engine door, and the native composition's dry run reaches the ObjectQL echo. A host that wired no field types is not judged.account.name_calc): the cross-object path hands the related objectgroupBy: ['id', attr], which the engine refuses only once a base row has a key to resolve.assertCrossObjectAttrsStoredjudges those keys with the service copy inexecuteCrossObject(after the base aggregate, before any related read) and in the echo, so the query and the dry run give one answer on any data.File surface. The seat approved one addendum before the PR:
packages/services/service-analytics/src/strategies/native-sql-strategy.ts(the decline).strategies/objectql-strategy.tswas NOT in that addendum; the first version of this body said it was, which was wrong. Why that file moved (+33 / −1):assertDatasetGroupTargetsStoredbesideassertDatasetDateBucketsTemporal:/analytics/sqlon the ObjectQL composition is answered by this echo, the dry-run call site wheremainalready calls the analytics: a date bucket over a formula field with no stored column renders strftime in the /analytics/sql dry run and serves one null group in the draft preview, while both live faces refuse from the driver #29167 service copy. The dispatch surface names the service's dry-run call sites, but not this file, so it is named here.assertCrossObjectAttrsStored(one helper, called in the echo and inexecuteCrossObject) goes beyond the named call sites. While pinning the faces I foundaccount.name_calc(a dimension through a relationship over the related object's formula) still rendered on the dry run and served on an empty base, because the engine refuses it only once a base row has a key to resolve. I added it without asking the seat for an amendment first. It is pinned by theaccount.name_calcrows inobjectql-echo-non-date-bucket.test.ts. The seat ruled that it stays in this PR: a cross-object dimension over a formula is still a dimension naming a formula field, so it is this card's defect on another path. The seat's ACCEPT namesstrategies/objectql-strategy.ts(both sites) in the surface addendum.Fixtures the narrowing moved (fixture triage, not new behaviour):
engine-aggregate-reference-verdict-positions.test.ts: athaving, a formula column can no longer be an aggregated column, so a formula pair there is refused first by the storage door (INVALID_FIELD), as a list-or-object pair already was. The "addDays pair from a formula projection" case now asserts that refusal atgroupBy.0.dimension-structured-json-door.test.tsandmulti-value-json-stored-door.test.tsGUARDs sweep everyFieldTypethrough a native-only harness:formulanow declines out of native and that harness configures no ObjectQL path, so the guards record it as not this door's refusal.Follow-up card: #30773.
dataset-compiler.tsrefuses a formula as a dataset measure but still accepts a dataset or cube dimension over one, so an author can publish a dimension that every query now refuses. The authoring-time refusal (Prime Directive 12,Metadata-protocol: narrowed) is that card's, out of this one's scope (contract review 6674767869, should-fix).Tests
packages/objectql/src/engine-aggregate-query-shape-door.test.ts(+2 and two control rows): a plain group overdoubled/opened_calcas a name and as{ field }/{ field, alias }, full message,path,param,field,fields,object, no driver read; the first offending position wins; the REST door throughfindDataandmapDataError. Controls:statusas a name and{ field: 'status' }served.preview-date-bucket-type.test.ts(+3): native, ObjectQL and preview faces give one refusal, field for field, for a formula withreturnTypedateandtext; the later position after a stored dimension; a stored dimension served alike on all three faces.objectql-echo-non-date-bucket.test.ts(+5):/analytics/queryand/analytics/sqlon the default (native) composition give one refusal for an inferred dimension, after a stored one, a cube dimension'ssql, andaccount.name_calc. Route pin: a stored dimension, a measure-only query and a cube dimension stay native (engine.aggregatenever called, the nativeCOALESCEstatement served); a formula dimension declines and the engine judges it (oneengine.aggregatecall).fallback-delegate-date-bucket-type.test.ts(+2): the delegated face refuses a plain formula group as the engine face does, query and dry run, delegate never called.packages/qa/dogfood/test/analytics-formula-group-refusal.dogfood.test.ts(new, 5): the stock showcase over HTTP,showcase_project.budget_remaining(a currency formula) on/analytics/queryand/analytics/sql: 400INVALID_FIELD, the plain-group sentence and remedy, oneerrorbody on both doors;statusserved on both.Acceptance notes
calc_sum: "sum does not accept that type"), so the route pin uses a stored measure.queryCapabilitieswithobjectqlAggregate: falseand no fallback) now answers "No strategy can handle query" for a formula dimension, where it compiled aGROUP BYthe database refuses. No shipped composition reaches it: the plugin derives both flags from the bridges it wires. The same cost the #20802 analytics half (domain:services): the cube read and the analytics read scope answer{ relation: { field: value } }as the engine seam now serves it — as the caller, capped, one answer on every face #20887 / security(analytics): the native-SQL analytics path never runs engine read middlewares, so object-scoped read gates (comment threads, activity rows measured; attachments, approval payloads unmeasured) do not apply there #21080 declines carry.account.name_calcis refused at the position of the cross-object path's own FK-resolve query on the related object (aggregate('…account'): groupBy.1), a position the caller never wrote. It is the engine's answer for that query, now given on any data and on the dry run too; the structured-JSON door documents the same trait for its ObjectQL face.Verification
Measured on
origin/main447226b2with a realSqlDriver(scratch test, deleted), a dataset and a cube dimension overcalc(formula,returnTypedate) andcalc_s(returnTypetext):/analytics/query, native compositionDATABASE_ERROR/ 500INVALID_FIELD/ 400,groupBy.0/analytics/query, ObjectQL compositionINVALID_FIELD/ 400, nopath, "run schema sync"/analytics/sql, both compositionsSELECT calc AS "calc" … GROUP BY calc[{ calc: null, total: 150 }]engine.aggregate, in-memory aggregationnullgroupINVALID_FIELD/ 400,groupBy.NControls at head, measured the same way: a
regiondimension and a measure-only query are served by the native strategy on both doors, as onmain.Ablations (each restored, `git diff HEAD` empty after)
Through
scripts/ablation-replace.mjs(anchor hit 1 → 0, blob moved), service-analytics imports@objectstack/objectqlfromsrc(vitest alias), so no rebuild:assertGroupByTargetsAreStoredcall deleted fromengine.aggregate: objectql 3 red (both analytics: a plain groupBy over a formula field answers DATABASE_ERROR 500 on the native composition, INVALID_FIELD 400 on ObjectQL, and a null group on the preview and in-memory engine #29304 pins and the analytics: a date bucket over a formula field with no stored column renders strftime in the /analytics/sql dry run and serves one null group in the draft preview, while both live faces refuse from the driver #29167 bucket pin), service-analytics 18 red across the three face files.assertDatasetGroupTargetsStoredmade a no-op: service-analytics 18 red (preview, echo and fallback pins, plain and bucket).Suites, gates and dogfood at 42c89af
The head after the contract review,
6bcd5781, changes only the changeset (Clause-②: no (narrowing), and the BREAKING heading "Who moves"). On it:check-changeset-no-major --base origin/main,check-adr-0087-registration --base origin/main,check-empty-changeset --base origin/mainandpnpm check:changeset-gate-self-testsall exit 0;scripts/pm/clause2-line.mjsreadClause2Linereads the changeset asvalue: no, arm: narrowingandreadMetadataProtocolLineasnone. The full dogfood run below carries over to that head, per the #29167 precedent.@objectstack/objectqltest: 556 files / 11071 tests passed;typecheckexit 0.@objectstack/service-analyticstest: 211 files / 5007 passed, 300 skipped;typecheckexit 0.eslint --no-inline-configover the 13 changed.tsfiles: exit 0.!),pnpm --filter @objectstack/dogfood testunder the verify lock: VERDICT command-exit 0, Test Files 422 passed / 3 skipped (425), Tests 3521 passed / 36 skipped (3557). The new pin alone: 5 / 5.dispatch-gates --commandsat42c89af2: 68 derived, 67 run with exit 0, 1 NOT MEASURED (check:dual-build-cjs-loads, exit 3: needs a whole-tree build; CI runs it).--ranreconcile: 0 UNRUN.Skills-docs:
git grep -i formula -- skills content/docs, read for grouping, dimension, bucket and analytics. No page says a formula can be grouped.skills/objectstack-data/rules/field-types.mdsays a formula is "virtual: not aggregatable", which still holds; it does not name grouping. It is a governed surface, so it is not touched here; a skills seat can add "or grouped by" there.Human Minimum
packages/specholds no runtime logic.pnpm lintpasses. (Locally: eslint over the changed files; the repo-wide run is CI's.)main, one pull request.packages/spectouched: its generated artifacts are regenerated and committed. (Not touched.)content/docs/references/,packages/*/CHANGELOG.mdorcontent/docs/releases/.