Skip to content

fix(service-analytics): a dataset selection asking one column twice is refused with a repair the selection can make, not the engine's "own 'alias'" - #28851

Merged
objectstack-fleet[bot] merged 1 commit into
mainfrom
claude/project-thread-0o422x
Nov 2, 2026
Merged

objectstack-fleet[bot] merged 1 commit into
mainfrom
claude/project-thread-0o422x

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

domain:server#3 · os-project-manager · session_01FEkQ5kbUJTTWAxjV9nqr1P

Requested by Leehom · project thread

Fixes #28798

Clause-②: no — the accept set of queryDataset is unchanged on every face: each selection served on main is served, each refused is refused with status 400. Only the refusal's prescription moves, and the ObjectQL strategy now refuses these keys itself, right before engine.aggregate. Nothing is widened: no package-root export, public member, answer member, error code or status is added.
Metadata-protocol: none
Skills-docs: none — no file under skills/** or content/docs quotes the duplicate-column refusal or tells a dataset author to alias a group entry.

Card thread: session_01FEkQ5kbUJTTWAxjV9nqr1P.

维护者速读

  • 改了什么:数据集查询里同一列被要两次(原始列加它自己的时间桶,或同一列的两个粒度),三条执行面(原生 SQL、ObjectQL、草稿预览)现在用同一个数据集拒绝回答。code、状态码、param、path、object、fields 都不变,只换处方:不再叫作者「给分组项起一个 alias」(数据集选择里没有 alias),而是点名选择里涉及这一列的成员,并给出能做的修法:去掉原始维度或去掉那个时间桶的粒度;两个粒度时只留一个。
  • 为什么改:卡 analytics: the duplicate-column refusal tells a dataset author to "give one of the group entries its own alias", which a dataset selection cannot do #28798(p3),是 PR fix(service-analytics)!: the draft preview refuses one column asked raw beside its own time bucket, as the live faces do #28794 合约评审的 should-fix 2。席位裁定:预览门变成数据集面唯一的拒绝,ObjectQL 策略在 engine.aggregate 之前调用它;引擎的文字不动。
  • 风险与回滚:接受集合不变(之前服务的仍服务,之前拒绝的仍 400 拒绝),只是拒绝文字变了。有一处要你知道:一个选择同时犯两个错(既把文本列按月分桶,又把同一列要两次)时,两个线上面以前答引擎的「非日期列不能分桶」(INVALID_FIELD),现在答本拒绝(INVALID_QUERY),与草稿预览原本的回答一致。见下文「一处优先级变化」。回滚即 revert。
  • 先看:packages/services/service-analytics/src/dataset-group-column-door.ts(由 preview-group-column-door.ts 改名重写),strategies/objectql-strategy.ts 中 execute 和 executeCrossObject 在 ctx.executeAggregate 前各一行调用。
  • 两分钟验证:pnpm --filter @objectstack/service-analytics exec vitest run src/__tests__/preview-time-filter-fk-parity.test.ts,预期 37 passed。
  • 需要你做的:无,席位落地;那处优先级变化由席位裁定。

What changes

  • One refusal for every dataset face. preview-group-column-door.ts becomes dataset-group-column-door.ts, and assertPreviewGroupColumnsDistinct(query, cube) becomes assertDatasetGroupColumnsDistinct(object, groupBy, query, memberColumn). It judges the groupBy keys it is handed, so each caller hands it the keys it built:

    • ObjectQLStrategy.execute calls it on aggregateGroupKeys(query, cube), right before ctx.executeAggregate;
    • executeCrossObject calls it on its own keys (an FK column for a cross-object dimension), right before its base ctx.executeAggregate;
    • the draft preview calls it where it called the old door, on aggregateGroupKeys(q, cube) over aggregateObjectOf(cube).

    Each call sits right before the engine call, so every refusal the strategy gave before keeps its precedence.

  • The sentence. The head and tail are the engine's, so a reader of both sees the same shape. Only the prescription between them differs, and it names the selection's members over the column, read with the strategy's resolveCubeFieldName:

    • raw beside its bucket (either key the bare column): "The selection asks for 'closed_at' both raw and bucketed (dimensions 'closed_at'; timeDimensions 'OBJECT.closed_at' by month). Drop the raw dimension from dimensions, or drop the granularity from the timeDimensions entry."
    • two buckets: "The selection asks for 'closed_at' at two granularities (timeDimensions 'closed_at' by month, 'closed_at' by year). Ask one granularity of it in timeDimensions."

    A dimension bucketed by its own timeDimensions entry is not listed as raw. "Drop the granularity" rather than "drop the entry", because a timeDimensions entry without a granularity still windows the query.

  • Surface. resolveCubeFieldName becomes a module-level export of objectql-strategy.ts, as aggregateGroupKeys and aggregateObjectOf already are. The package root re-exports ObjectQLStrategy by name only, so the root surface is unchanged. ObjectQLStrategy gains no member. The door imports only a type from the strategy module, so there is no runtime import cycle.

  • Untouched. packages/objectql is untouched, and a direct engine.aggregate caller still gets the engine's "own 'alias'" prescription (pinned). generateSql (/analytics/sql, analytics: the /analytics/sql dry run renders one bucket for a selection the query refuses (two granularities of one column, a column raw beside its bucket) #28841) is untouched.

  • Tests: src/__tests__/preview-time-filter-fk-parity.test.ts (34 → 37 cases). Changeset: .changeset/28798-dataset-column-twice-refusal-prescription.md, @objectstack/service-analytics patch.

Why

Measured on origin/main fa59f787 with a probe over real SQLite (all three faces):

  • Every duplicate reaches the strategy. Two keys over one column need a bucket, because the strategy names identical keys once (analytics: the ObjectQL strategy refuses two dataset dimensions over one column with INVALID_QUERY (duplicate groupBy), while native and the preview serve both names #28744). The native strategy declines every bucketed time dimension, so both live faces answered from the ObjectQL strategy's call into the engine. There is no native-own refusal on any of these selections. This held for raw beside a bucket of another spelling, a second dataset dimension over the column, and two buckets.
  • Every face prescribed the alias. All three answered … The query was NOT run. Give one of the group entries its own 'alias'. …, and a dataset selection has no alias.
  • A direct engine.aggregate with ['closed_at', { field: 'closed_at', dateGranularity: 'month' }] answers the same refusal. That is the backstop this change leaves in place.

One precedence change (for the seat's ruling)

The engine runs its other groupBy doors before the duplicate-column door. One of them refuses a date bucket over a non-date column (INVALID_FIELD). Measured on main, the selection dimensions: ['region'] with timeDimensions: [{ dimension: 'zone', granularity: 'month' }] (zone is declared over the text column region) answered:

  • both live faces: INVALID_FIELD, groupBy.1.dateGranularity;
  • the draft preview: the column refusal.

With the door before engine.aggregate, as ruled, all three faces now answer the column refusal (INVALID_QUERY, groupBy.1.field). It is still a 400 refusal, and fixing it meets the other refusal. The new pin [#28798] answers it on every face before the engine judges a bucket over a text column holds this. No other measured selection changed. The os-dev-report carries this as an open question with options.

Tests

Red first, green, ablations
  • Red on base. analytics-service.ts, strategies/objectql-strategy.ts and preview-group-column-door.ts were restored to fa59f787, and the new door file was moved aside. The test stayed at HEAD. Result: Tests 11 failed | 26 passed (37).

    • Every refusal case failed on the message: Received: "… Give one of the group entries its own 'alias'. …".
    • The precedence pin failed on the live faces' INVALID_FIELD.
    • The direct-engine pin passed.

    Restored with git checkout HEAD --, then git status was clean.

  • Green. 37 passed (37). The full service-analytics suite gave Test Files 203 passed (203), Tests 4876 passed | 282 skipped. tsc --noEmit and scoped eslint both exited 0.

  • Ablations used node scripts/ablation-replace.mjs with the fix committed first. Each anchor went 1 → 0, and each restore came back with blob == HEAD and an empty git diff HEAD:

    • A1: delete the execute call, so the engine answers again. Result: 9 failed | 27 passed, every live-face refusal pin on the direct path.
    • A2: delete the executeCrossObject call. Result: 1 failed | 35 passed, the cross-object pair.
    • A3: make the preview's call a no-op. Result: 9 failed | 27 passed, because the preview serves both columns again.
    • A4: always use the two-granularity sentence. Result: 3 failed | 33 passed, the raw-beside-bucket cases.
    • A5: list a dimension bucketed by its own entry as raw. Result: 1 failed | 36 passed.
    • A6: compare a timeDimensions member's spelling instead of its column. Result: 5 failed | 32 passed.

Out of scope (reported to the seat, not fixed)

  • On main, the draft preview serves a bucket over a text column (timeDimensions: [{ dimension: 'zone', granularity: 'month' }]) as one null bucket holding the total. Both live faces refuse it with INVALID_FIELD. That is a separate preview parity gap.

🤖 Generated with Claude Code

https://claude.ai/code/session_01FEkQ5kbUJTTWAxjV9nqr1P


Generated by Claude Code

…s refused with a repair the selection can make, not the engine's "own 'alias'"

- dataset-group-column-door.ts (was preview-group-column-door.ts):
  assertDatasetGroupColumnsDistinct(object, groupBy, query, memberColumn)
  is the one refusal for every queryDataset face. It judges the keys it is
  handed, with the engine's code, status, param, path, object and fields,
  and a prescription naming the selection's members over the column: drop
  the raw dimension or the bucket's granularity, or ask one granularity.
- objectql-strategy.ts: execute and executeCrossObject call it on the keys
  they built, right before ctx.executeAggregate, so native (which declines
  every bucketed selection to this strategy) and ObjectQL answer it.
  resolveCubeFieldName becomes a module-level export (not root).
- analytics-service.ts: the draft preview calls it on aggregateGroupKeys.
- The engine door in packages/objectql is untouched; a direct
  engine.aggregate caller keeps its alias prescription (pinned).
- Tests on real SQLite: the sentence pinned verbatim on all three faces,
  the faces agree field for field, the cross-object path, the raw-member
  listing, and the precedence pin for a bucket over a text column.

Co-Authored-By: Claude <noreply@anthropic.com>
@github-actions

github-actions Bot commented Nov 2, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/service-analytics, touching 9 documentable anchor(s).

17 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json 3e62a22c4f31c6b8ada6e6e3b3c96996396edec6.

⛔ 6 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • 2 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 60 of 216 client-bound route-ledger rows — the other 156 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 156: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 101 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 10 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 3e62a22c4f31c6b8ada6e6e3b3c96996396edec6 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 1bed4a453bf4499f971e665d45ee3f6d2fc931ef — the merge of head ac21b1b68431d4179834bbc021f601fb61f071f7 into base 3e62a22c4f31c6b8ada6e6e3b3c96996396edec6, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 1bed4a453bf4499f971e665d45ee3f6d2fc931ef && git checkout 1bed4a453bf4499f971e665d45ee3f6d2fc931ef
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 3e62a22c4f31c6b8ada6e6e3b3c96996396edec6 ac21b1b68431d4179834bbc021f601fb61f071f7 && git checkout -B drift-repro 3e62a22c4f31c6b8ada6e6e3b3c96996396edec6 && git merge --no-ff ac21b1b68431d4179834bbc021f601fb61f071f7

node scripts/docs-audit/affected-docs.mjs --json 3e62a22c4f31c6b8ada6e6e3b3c96996396edec6

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 3e62a22c4f31c6b8ada6e6e3b3c96996396edec6 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#3 · os-project-manager · session_01FEkQ5kE5BrPUPNMMyv6oCH

席位核对 · ACCEPT · PR #28851 · 2026-11-02T01:05Z

Head-sha: ac21b1b68431d4179834bbc021f601fb61f071f7

维护者速读(席位终稿)

核对六项

  1. 形态:draft → main;Fixes #28798,与派单 pm-orders/server/order-28798.md 一致。文件 6 个,在认领 6565819778 申报面内(旧门文件改名为 dataset-group-column-door.ts)。
  2. 前提实测:base 上红 11/37,head 绿;消融 A1–A6 全红后还原,其中去掉策略调用即回到引擎原话。席位读 diff:两处调用位于 executeAggregate 之前、其余策略拒绝之后;resolveCubeFieldName 改为模块级导出,包根 src/index.ts 未动。
  3. 门禁:本 head check-runs 35 success、13 skipped(路径未命中)、0 失败或待跑。dev 本地 62 条派生门禁全部 exit 0,0 NOT-MEASURED。
  4. 接受的偏差:同时犯两错的选择(原值 region 旁再要文本列上的 zone 按月)在线上两面由 INVALID_FIELD 变为 INVALID_QUERY(仍 400、仍拒绝,与预览原本的回答一致)。席位裁定保留,见 6566430977。
  5. 范围外去向:草稿预览把文本列上的时间桶当成一个 null 桶服务,而线上拒绝 INVALID_FIELD。已立卡 analytics: the draft preview serves a time bucket over a text column as one null bucket, where the live faces refuse INVALID_FIELD #28852(p3,pm:blocked 待本 PR)。analytics: the /analytics/sql dry run renders one bucket for a selection the query refuses (two granularities of one column, a column raw beside its bucket) #28841(/analytics/sql 回显)待本 PR 合入后在同一线程派发。Metadata-protocol: none。
  6. 落地条款:非 spec 路径、Clause-②: no(接受集合不变),不欠契约复核,席位核对即落地依据。semver:@objectstack/service-analytics patch,与 changeset 一致。check-governed-merges --pr 28851:NOT governed,357 行。落地授权,维护者原话:「按照 pm-dispatch 规则复核过的都授权」(2026-10-25T11:35Z)。

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review November 2, 2026 01:06
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge November 2, 2026 01:06
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Nov 2, 2026
Merged via the queue into main with commit 7e60dc2 Nov 2, 2026
49 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/project-thread-0o422x branch November 2, 2026 01:27
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

analytics: the duplicate-column refusal tells a dataset author to "give one of the group entries its own alias", which a dataset selection cannot do

1 participant