Uh oh!
There was an error while loading. Please reload this page.
fix(service-analytics): judge both ObjectQL doors by one filter-member view (#10759) - #10862
Conversation
…r view (#10759) `ObjectQLStrategy.planCrossObject` reads `Object.keys(filter)` and nothing else, and the two call sites handed it different things. `generateSql()` handed it every member the `where` touches, flattened out of the tree; `execute()` handed it the built engine filter, where anything structural (`$or`, `$not`, an unmergeable nested `$and`) has been folded into `filter.$and` and the only readable key is the literal `$and` — never a field name. So a cross-object reference nested in a combinator was refused by the preview and accepted by the execution door. `engine.aggregate` cannot join, and the accepted half did not answer the cross-object query: the branch naming a column the base object does not have can never match, so the query silently collapsed to its remaining branches. That is the silent mis-bucket #3654's loud refusal exists to prevent, and the file already stated the invariant it was breaking. Both callers now derive the member list from one `filterMemberView`, so "the preview accepts/rejects the same set" holds by construction instead of being restated at two call sites that can drift. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PnJHU45vPJj5UQrxe946Bx
📓 Docs Drift CheckThis PR changes 1 package(s): ⛔ 2 release-owned page(s) name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 8 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 deecc351a4dbb1f977c77741556a9993d0886bbc && git checkout deecc351a4dbb1f977c77741556a9993d0886bbc
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin db82944559893eda5e0eb171c8bdf9fff23638a7 ddfdf335f57f552c2c9c3e0aa1ad835f9f8a6621 && git checkout -B drift-repro db82944559893eda5e0eb171c8bdf9fff23638a7 && git merge --no-ff ddfdf335f57f552c2c9c3e0aa1ad835f9f8a6621
node scripts/docs-audit/affected-docs.mjs --json db82944559893eda5e0eb171c8bdf9fff23638a7
|
Uh oh!
There was an error while loading. Please reload this page.
⛔ merge queue 构建失败 — 先分诊,再决定要不要重排队列构建 32543579286 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集), 失败的 job(日志抽取,best effort):
跨 PR 相同签名(24h,按失败测试文件聚合):
历史信号:
分诊清单:
Generated by Claude Code · merge-queue-triage workflow (#4859) |
Fixes#10759
The defect, reproduced before it was touched
The card was read from the code, not executed. Both doors were fired against one fixture in one run on
origin/mainat5f2e54cc6(PR #10758 merged), and they answer differently for the same query:planCrossObjectreadsObject.keys(filter)and nothing else, and the two call sites handed it different things.generateSql()handed it every member thewheretouches, flattened out of the tree.execute()handed it the built engine filter — where an AND-ed leaf sits at the top level and is seen, but anything structural (an$or, a$not, a nested$andthat cannot merge) has been folded intofilter.$and, so the only readable key was the literal$and, which is never a field name.engine.aggregatecannot join. The half that returned rows was not answering the cross-object query: the disjunct naming a column the base object does not have can never match, so the query silently collapsed to its remaining branches and reported a narrower figure as if it were the answer. That is the silent mis-bucket #3654's loud refusal exists to prevent, and the file already stated the invariant it was breaking:The change
Both callers now derive the member list from one
filterMemberView(cube, query), so that sentence holds by construction rather than by two call sites happening to agree. The alignment direction is the one the inherited ruling chose: a loud 400 over a silent mis-bucket, and the echo door — which was already right — is untouched.One member view removed from
execute()'s inventory, deliberately: the time-dimension windows, which live indateRangeBoundsrather than inwhere. They need no arm there —planCrossObject's own first loop overquery.timeDimensionsalready refuses a cross-object time dimension, and refuses it as the dimension the author wrote rather than as the lowered predicate it becomes, which is the better diagnostic and the reason that loop runs first. This makes the two doors' inventories identical rather than merely equivalent.Is the widened refusal set inside the declared invariant?
Yes, and measurably so. Both call sites now compute the view from the identical expression, so the set
execute()newly refuses is exactly the setgenerateSql()already refused today — no more. Nothing was widened past what the invariant covers, and no new judgement about the refusal set was made.The #10758 interaction, measured
PR #10758 added a second producer of the
$andconjunct shape (a dataset's definition-levelfilter), so the question was whether a query that used to pass now starts hitting this defect. Measured on today's tree, over the same fixture set:is_deleted: false), nowhere$ornamingaccount.regionaccount.region), nowhereSo an ordinary dataset scope does not become a refusal, and it does not shield a cross-object member in the caller's own combinator either. The third row is a real defect and is not this one: both doors agree there, because neither call site's member view contains the dataset scope at all, so there is no preview/execution divergence to restore. Filed separately as #10861 (a sub-issue of #10413) rather than widened into this PR — refusing it is a placement decision (query-time versus a contract-first rejection in
dataset-compiler.ts) rather than a mechanical fix. Its current behaviour is pinned as-is in the new test file, with the paragraph above it explaining why, so the day that decision lands the pin goes red and points at it.Pins, in four directions
packages/services/service-analytics/src/__tests__/crossobject-conjunct-refusal.test.ts, 14 tests:$or, in$not, and two levels deep is refused on the execution door with the ADR-0112 envelope —codeandstatus, plus the member andparamthe request spelled, andengine.aggregatenever reached. Asserting only that it throws would pass on a bareErrorand report the platform broken for a caller mistake.$orquery shipping today.generateSql()door is unchanged, asserted as a two-door agreement over one query rather than as two independent expectations, so the invariant itself is what is pinned./api/v1/analytics/querystill drops per-measure and dataset-levelfilteron the ObjectQL path —engine.aggregatereceives no filter at all #10413-phase-1 dataset filter conjunct is not misread — it travels in$andexactly where a combinator travels.Zero-hit counter-check: the same member at the top level is pinned in the same file. It is refused on both doors before and after, so it proves the fixture, the cube and the detection path work, and cannot be read as evidence for the change. The nested rows are the only ones that moved.
Ablation, direction predicted before running
Predicted: reverting
execute()'s third argument to the pre-fix engine-filter view turns exactly 7 of the 14 new tests red — the three envelope rows, the three agreement rows, and the dataset-scope-does-not-shield row — while the top-level control, the three clean-combinator rows, the cross-object-dimension row and both dataset-filter rows stay green. Ordinary direction; no inversion available (nothing counts verdicts, no rule was narrowed).Measured: 7 failed | 1755 passed (1762), and the seven names are exactly the predicted seven.
Restore proved byte-identical:
git hash-objecton the strategy reads720c84d0f2a4d091dce27ad76cfe9d3d705b9da3both before the mutation and after the restore, and the restored leg is 80 files / 1762 passed.Positive control for the resolution path:
service-analytics/distwas present and byte-identical across both legs (all six artifacts, verified bygit hash-object) — no rebuild happened between them — and the mutation was observed anyway. So this suite resolves the strategy throughsrc/, measured rather than assumed; adist-resolving suite would have stayed green through the mutated leg.Verification
@objectstack/service-analyticstests@objectstack/rest(downstream consumer) teststsc --noEmitGate union derived with
node scripts/pm/dispatch-gates.mjs, no path arguments, on a clean tree at the final commitddfdf335f; every exit code captured before any pipe. All green:check:changeset-gate-self-tests·check:objectui-changeset·check:slot-lookup·check:test-source-alias·check:type-source-resolution·check-adr-0087-registration·check-changeset-no-major·check-empty-changeset·check-plugin-teardown-shape·check-affected-docs·check:query-options-erasure·check:type-check-coverage·check:type-check-debt(--re-measure, on the built closure) ·check:engine-double-contract·check:where-matcher·check:nul-bytes·check:route-envelope·check:dispatcher-error-vocabularyThe last two are the class-#10309 pair, run explicitly (each self-tests first); the path derivation did not name them.
Changeset
minor, declared breaking. A widened refusal set means a query that returns rows today can start returning400 INVALID_FIELD— narrow, and only on a deployment whose driver reportsobjectqlAggregatebut notnativeSql, but real. ADR-0087 disposition:not-required (no-migration-prescription)— the affected input is an ad-hoc request body, not a metadata surface; no authorable key, export or config field is removed or renamed, soobjectstack migrate metahas nothing to rewrite and no stored shape converts. The gate reports it asADR-0087 exemption (no-migration-prescription).Generated by Claude Code
Generated by Claude Code