Repository navigation
Commit c01690e
fix(service-analytics)!: members read through a relationship path answer one refusal on the native, ObjectQL and draft-preview faces (#28686)
domain:server#3 · os-project-manager · session_01FEkQ5kE5BrPUPNMMyv6oCH
<!-- claude-projects-attribution: {"github_login":"os-project-manager"}
-->
_Requested by **Leehom** · [project
thread](https://claude.ai/code/project/chan_01TenzsRopZj4jMbvjX8uz1X?thread=cmsg_01TenzsRopZj4jMbvjX8uz1XB1hsVtuncZ5F5C4zpiftHC)_
Fixes #28555
Fixes #28556
Fixes #28557
Fixes #28558
Clause-②: no (narrowing) — the draft preview (`queryDataset` with
`previewDrafts: true`) now refuses `INVALID_FIELD` / 400 any member it
would read through a relationship path (a dimension, time dimension,
measure or filter member), which it served as one `null` group, `[]` or
a total of `0`, because it joins nothing; a `timeDimensions` entry and a
`runtimeFilter` member spelled through a relationship path the dataset's
`include` does not declare are refused `INVALID_FIELD` / 400 on every
face, where the native strategy answered `DATASET_INVALID` / 400 and the
preview served; and a declared path to a column the related object lacks
is refused `INVALID_FIELD` / 400 before SQL, where the native strategy
answered `DATABASE_ERROR` / 500. Nothing is widened: no export, answer
member, error code or status is added; every code was already on the
wire.
Metadata-protocol: none
Skills-docs: none — no file under `skills/**` or `content/docs` says the
draft preview joins a related object or names the code a selection-side
relationship refusal answers;
`content/docs/data-modeling/analytics.mdx`'s "Only declared paths are
joinable" is now true of time dimensions and filter members too.
**BREAKING** accept-set narrowing on the `queryDataset` draft preview
(`@objectstack/service-analytics`), and a code change on two native
refusals.
Card thread: `session_01FEkQ5kbUJTTWAxjV9nqr1P`.
## 维护者速读
- **改了什么**:数据集查询里经关系路径(点号)读字段的成员,三条执行面(原生
SQL、ObjectQL、草稿预览)现在给一致的答复。草稿预览只读本对象的草稿种子行、不做 join,所以遇到 `account.name`
这类已声明的关联字段(维度、时间维度、度量或筛选)改为 400 `INVALID_FIELD` 明确拒绝并说明「预览不 join
关联对象,请发布后查询或去掉该成员」,不再给一个 `null` 分组;`对象名.region`、`数据集名.region`
这种解析到已声明维度的写法在预览里给出与线上一致的分组。未在 `include` 声明的路径写在时间维度或 `runtimeFilter`
里,三面同一条 400 `INVALID_FIELD` 消息;关联对象上不存在的列(`account.bogus`)在发 SQL
前拒绝,不再是原生面的 500。
- **为什么改**:卡 #28555(p2)、#28556、#28557、#28558(p3),都是 #28258 开发者在真 SQLite
上测出的同族缺口:作者在草稿里看到一个看似合理的总数,发布后数字却变了;同一个写错的筛选三面三种答复;一个拼写错误变成服务器 500。
- **风险与回滚**:线上两条策略对已声明路径照旧服务(ObjectQL 面自身的跨对象限制不变);读不了的关联对象仍先给 403;手写
cube 的配置白名单和其它所有原因仍是
`DATASET_INVALID`。预览对关联字段由「错的数」变为「明确拒绝」,这是收窄,需先过合约评审。仓内唯一读
`DATASET_INVALID` 的运行时消费方(`@object-ui/data-objectstack` 的 `queryDataset`
适配器)对任何具名错误码一视同仁。回滚即 revert。
- **先看**:`packages/services/service-analytics/src/analytics-service.ts`
的 `relationshipMembers`、`assertDimensionJoinsBacked`(扩到时间维度和筛选叶子)、新的
`assertRelationshipColumnsExist` 与
`assertPreviewJoinsNothing`,以及预览闸门列表末尾的三处调用;`previewEvaluationCube`。
- **两分钟验证**:`pnpm --filter @objectstack/service-analytics exec vitest
run src/__tests__/dotted-path-preview-parity.test.ts`,预期 15 passed。
- **需要你做的**:无,席位落地(收窄需先过合约评审)。
## What changes
All in `packages/services/service-analytics/src/analytics-service.ts`,
reusing PR #28553's gate, its `unbackedRelationshipPath` and
`invalidMemberError`; no second judgement of "is this path backed".
- **`relationshipMembers(query, cube, withMeasures)`** (new,
module-level). Every request entry that reads a column through a
relationship path, in request order: `dimensions`, `timeDimensions`,
optionally `measures`, then the `where` leaves read through the lowering
both strategies compile with (`normalizeAnalyticsFilterTree` +
`collectFilterLeaves`, as `namedQueryFields` reads them, so the
dataset's own `filter` and a measure's `filter`, which `DatasetExecutor`
folds into the query's `where`, are included). Each is resolved with
`declaredMemberEntry` (the strategies' `lookupMember` mirror), so
`OBJECT.region` / `CUBE.region` and expressions are not in it.
- **#28556, #28557: `assertDimensionJoinsBacked` covers every
position.** It now iterates `relationshipMembers` instead of
`dimensions` alone, so an undeclared dotted `timeDimensions` entry
(`param: 'timeDimensions'`) and `where` leaf (`param: 'where'`) are
refused with the same message shape (`Time dimension '…'` / `Filter
member '…'` … `traverses relationship path '…', which the dataset does
not declare in its include…`). The `dimensions` message is
byte-unchanged. Same two call sites as before (`callCtx` after the
field-level verdicts, and the preview's gate list), so the three faces
answer byte-equal messages and #20933's 403 order holds.
- **#28558: `assertRelationshipColumnsExist`** (new private, called
right after the join gate at both sites). For a member whose path IS
backed, it resolves the last hop's object with `resolvePathHops` and
`this.hopReference` (the resolver the admission and strategies read) and
checks the column against `getObjectFieldNames` (plus `id` /
`created_at` / `updated_at`, as the base-object gates admit). Missing ⇒
`invalidMemberError` with `member` / `param` / `cube`, plus `field` and
`object`, the shape `assertDimensionFields` / `assertWhereFields`
attach. Same stand-downs as those gates (no probe, no answer for that
object).
- **#28555: the preview.** `assertPreviewJoinsNothing` (new private,
last in the preview's gate list) refuses any member
`relationshipMembers(…, true)` names, `INVALID_FIELD` / 400 naming the
member, saying the preview reads the base object's drafted seed rows
alone and joins no related object. `previewEvaluationCube` (new,
module-level) hands the evaluator a cube carrying an alias entry for
each requested dimension / time-dimension spelling that resolves to a
differently-keyed declared dimension, so `OBJECT.region` reads `region`;
every gate still reads the plain cube.
- **Which way #28555 went, and why.** The order allowed joining or
refusing. I refused. A join in the preview would read the RELATED
object's rows, which can live in its own pending seed, in the published
table, or both, keyed differently in each (a seed row references a
lookup by its external id, a stored row by `id`), and it would read an
object the preview branch neither admits nor scopes today (`cubeObjects`
names only the base object and declared joins, and no read scope is
applied to seed rows). That is a second query engine with its own
admission, not a fix in the dataset compiler's path. The refusal names
the member and the remedy; the `null` group looked like an answer. A
dotted spelling that resolves to a declared dimension is not a join, so
it is now answered, not refused.
- Comments only: the native join allowlist's docblock
(`native-sql-strategy.ts`) and the preview evaluator's header
(`preview-evaluator.ts`), which this diff made false.
- Tests: new `src/__tests__/dotted-path-preview-parity.test.ts` (15
cases, real SQLite, the #28201 / #28258 harness).
`dotted-dimension-unbacked-join-parity.test.ts`: the backed-path CONTROL
now pins the preview's refusal, and the `runtimeFilter` CONTROL that
pinned native `DATASET_INVALID` now pins the #28557 refusal.
`packages/rest/src/analytics-dataset-refusal-envelope.test.ts`: the
allowlist row's `runtimeFilter` trigger now answers `INVALID_FIELD` from
the service gate (`code` override and message), audit trail kept in its
comment.
- Changeset `.changeset/28555-dotted-path-preview-parity.md`,
`@objectstack/service-analytics` patch.
## Why
Measured on `origin/main` `0b6a52e8` (real SQLite, every face), with a
probe printing each face's answer per selection:
<details><summary>Before / after per selection (dataset over a deal
object, `include: ['account']` where "backed")</summary>
| selection | native before | ObjectQL before | preview before | after |
|:--|:--|:--|:--|:--|
| backed `account.name` dim | groups | groups | one `null` group, total
180 | live faces unchanged; preview refuses (joins nothing) |
| declared `account_name` over `account.name` | groups | groups | one
`null` group | same |
| `OBJECT.region`, `CUBE.region` dim | groups | groups | one `null`
group | preview answers the same groups |
| backed `account.bogus` dim | `DATABASE_ERROR` / 500 | `INVALID_FIELD`
(engine groupBy guard) | one `null` group | `INVALID_FIELD` / 400, one
message, `field: bogus`, `object` the account object |
| backed `account.bogus` runtimeFilter | `DATABASE_ERROR` / 500 |
`INVALID_FIELD` (cross-object filter) | `[]` | same one refusal, `param:
where` |
| unbacked `account.founded_at` time dim, bucketed | `INVALID_FIELD`
(cross-object bucket) | same | axis dropped, total 180 | one
unbacked-path message, `param: timeDimensions` |
| unbacked time dim, windowed only | `DATASET_INVALID` (allowlist) |
`INVALID_FIELD` (cross-object bucket) | total 0 | same one message |
| backed time dim, windowed only | total 130 | `INVALID_FIELD`
(cross-object bucket) | total 0 | native unchanged; preview refuses |
| unbacked `account.name` runtimeFilter | `DATASET_INVALID` |
`INVALID_FIELD` (cross-object filter) | `[]` | one unbacked-path
message, `param: where` |
| …under `$or` with `region: emea` | `DATASET_INVALID` | `INVALID_FIELD`
| the emea rows | same one message |
| backed `account.name` runtimeFilter | rows | `INVALID_FIELD`
(cross-object filter) | `[]` | live unchanged; preview refuses |
| backed `account.id` dim | groups | `INVALID_QUERY` (duplicate groupBy
column) | one `null` group | live unchanged; preview refuses |
</details>
## Tests
<details><summary>Red first, green, ablations</summary>
- Red on base: the three production files restored to `0b6a52e8` (`git
restore --source`), tests at HEAD `44e4ddbc`: `Tests 15 failed | 9
passed (24)` across the new file and the updated #28258 file; every new
refusal pin red, the controls green. Restored with `git checkout HEAD
--`, `git diff HEAD` empty.
- Green at `44e4ddbc`: the two files `24 passed (24)`.
- Ablations (`node scripts/ablation-replace.mjs`, each anchor 1 → 0,
restore blob == HEAD `c9f3025c6ad8` and `git diff HEAD` empty; the suite
imports `src`, no build between):
- A1 `--delete` the preview's `assertPreviewJoinsNothing` call: `6
failed | 18 passed` (the five #28555 refusals and the #28258 backed
CONTROL).
- A2 the evaluator handed `cube` instead of `previewEvaluationCube(q,
cube)`: `2 failed` (`OBJECT.region`, `CUBE.region`).
- A3 `--delete` the `callCtx` call of `assertRelationshipColumnsExist`:
`2 failed` (#28558 dimension and runtimeFilter).
- A4 `--delete` the `where`-leaf read in `relationshipMembers`: `5
failed` (#28557 ×2, #28558 runtimeFilter, the backed runtimeFilter
preview case, the #28258 runtimeFilter pin).
- A5 `--delete` the `timeDimensions` read in `relationshipMembers`: `3
failed` (#28556 ×2, the backed windowed preview case).
- `pnpm --filter @objectstack/service-analytics exec vitest run` at
`44e4ddbc`: `Test Files 202 passed (202)`, `Tests 4838 passed | 282
skipped`.
- `pnpm --filter @objectstack/rest exec vitest run src/analytics` (after
`--filter '@objectstack/rest^...' --filter
@objectstack/service-analytics build`): `Test Files 23 passed (23)`,
`Tests 298 passed | 22 skipped`.
- `tsc --noEmit` for service-analytics exit 0; `eslint
--no-inline-config packages/services/service-analytics/src
packages/rest/src/analytics-dataset-refusal-envelope.test.ts` exit 0.
</details>
## Acceptance notes
- `DATASET_INVALID` readers for the two native code moves (#28557's
`runtimeFilter` member, #28556's windowed time dimension), measured with
`git grep` across the tree, `ui/` included: the only runtime reader is
`@object-ui/data-objectstack`'s `queryDataset` adapter, whose branch ⑤
treats every named non-permission code identically; its two tests use
the code as fixture text. No reader in `packages/client`,
`packages/cli`, `packages/runtime` or dogfood. The one in-repo pin was
the rest refusal-envelope row (moved, above). So no real consumer, and
both halves land.
- On the dataset route, nothing a request names reaches the native join
allowlist any more; it stays for a hand-authored cube's configured hook.
`DATASET_INVALID` stays for every other reason it is raised.
- Out of scope, measured, reported to the seat (not fixed here): the
preview drops a bucketed `timeDimensions` entry that is not also in
`dimensions` (plain `closed_at`, live faces group by month, preview one
row); the preview answers a `runtimeFilter` member spelled
`OBJECT.region` with `[]` (it matches row keys as written); the ObjectQL
strategy answers a backed `account.id` dimension `INVALID_QUERY` / 400
about a duplicate groupBy column.
## Verification
Head is `efa6f756` (delta on `c4f1e6c2`, delta review PASS in PR comment
6559465368). The readings below were taken on `c4f1e6c2` unless marked.
- **Correction:** the local `tsc --noEmit` reading under Tests (exit 0)
was wrong for `c4f1e6c2`. CI's `Type Check · workspace` concluded
failure there: TS2352 at `dotted-path-preview-parity.test.ts` (213,61)
and (252,48), since the package typecheck includes `__tests__`.
`efa6f756` fixes it (the case tables use `satisfies`, no cast): `pnpm
--filter @objectstack/service-analytics typecheck` exit 2 on the old
file, exit 0 on the fix.
- `efa6f756` also refuses a dataset measure over a backed path to a
missing related column before SQL (native answered `DATABASE_ERROR` /
500; red leg by ablation, 1 failed | 15 passed, restored), and the rest
refusal-envelope row and five-entries pin now say the allowlist row is a
string-level audit. At `efa6f756`: service-analytics `Test Files 202
passed (202)`, `Tests 4839 passed | 282 skipped`; rest `src/analytics`
`23 passed (23)`; eslint exit 0; gates 62 derived, 61 run exit 0,
`check:dual-build-cjs-loads` NOT MEASURED locally (exit 3, prerequisite:
no full build), read from CI.
- `node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack`: 62 families derived on `c4f1e6c2` (7 paths
vs merge base `0b6a52e8a`); each run with stdin from `/dev/null`, exit
captured before any pipe, after a full `turbo run build
--filter=!@objectstack/docs --concurrency=2` (lock VERDICT command-exit
0). `--ran`: **62 derived, 62 run, 0 NOT-MEASURED, 0 UNRUN**, every one
`exit 0`.
- Hand gates: `node scripts/check-platform-checklist.mjs --changed-from
origin/main` exit 0 (1 anchor judged, 0 lost); `node
scripts/check-commit-card-trailers.mjs --range 0b6a52e..HEAD` exit 0.
- Dogfood battery (Clause-② carries a narrowing arm), on `c4f1e6c2`:
`pnpm --filter @objectstack/dogfood test` under the lock, VERDICT
command-exit 0, `Test Files 369 passed | 3 skipped (372)`, `Tests 2981
passed | 28 skipped (3009)`, no failure.
- `git merge-tree --write-tree origin/main HEAD` against `e4ec4f22` exit
0; the only open PR naming `service-analytics` is the version-packages
PR #26517.
- CI families: read from this PR's check-runs.
<details><summary>The 62 derived families and their exits</summary>
- `node scripts/check-adr-0087-registration.mjs --base origin/main` ::
exit 0
- `node scripts/check-adr-0087-registration.mjs --self-test` :: exit 0
- `node scripts/check-changeset-no-major.mjs --base origin/main` :: exit
0
- `node scripts/check-closing-keyword-parity.mjs` :: exit 0
- `node scripts/check-closing-keyword-parity.mjs --self-test` :: exit 0
- `node scripts/check-comment-mask-adoption.mjs` :: exit 0
- `node scripts/check-comment-mask-adoption.mjs --self-test` :: exit 0
- `node scripts/check-comment-mask-corpus.mjs` :: exit 0
- `node scripts/check-dts-emitted.mjs --self-test` :: exit 0
- `node scripts/check-empty-changeset.mjs --base origin/main` :: exit 0
- `node scripts/check-empty-changeset.mjs --self-test` :: exit 0
- `node scripts/check-issue-citations.mjs` :: exit 0
- `node scripts/check-plugin-teardown-shape.mjs` :: exit 0
- `node scripts/check-plugin-teardown-shape.mjs --self-test` :: exit 0
- `node scripts/check-tenant-audit-census.mjs` :: exit 0
- `node scripts/check-tenant-audit-census.mjs --self-test` :: exit 0
- `node scripts/ci/assert-turbo-remote-cache.mjs --self-test` :: exit 0
- `node scripts/ci/plan-test-core-shards.mjs --self-test` :: exit 0
- `node scripts/ci/prune-turbo-cache.mjs --self-test` :: exit 0
- `node scripts/docs-audit/check-affected-docs.mjs` :: exit 0
- `node scripts/docs-audit/check-drift-comment.mjs` :: exit 0
- `node scripts/pm/release-rehearsal-clone.mjs --self-test` :: exit 0
- `node scripts/release-pending-publish.mjs --self-test` :: exit 0
- `pnpm --filter @objectstack/spec run check:duration-unit-keys` :: exit
0
- `pnpm check:changeset-gate-self-tests` :: exit 0
- `pnpm check:ci-filter-parity` :: exit 0
- `pnpm check:closed-ceremony-text` :: exit 0
- `pnpm check:cross-package-test-inputs` :: exit 0
- `pnpm check:dispatcher-error-vocabulary` :: exit 0
- `pnpm check:doc-authoring` :: exit 0
- `pnpm check:driver-memory-census` :: exit 0
- `pnpm check:dts-closure` :: exit 0
- `pnpm check:dual-build-cjs-loads` :: exit 0
- `pnpm check:engine-double-contract` :: exit 0
- `pnpm check:error-status-conformance` :: exit 0
- `pnpm check:gitlink-declared` :: exit 0
- `pnpm check:issue-citations` :: exit 0
- `pnpm check:keyed-text-bounds` :: exit 0
- `pnpm check:lean-entry-closure` :: exit 0
- `pnpm check:logger-receiver-detach` :: exit 0
- `pnpm check:nul-bytes` :: exit 0
- `pnpm check:objectql-double-limit` :: exit 0
- `pnpm check:org-identifier` :: exit 0
- `pnpm check:page-declaration-shape` :: exit 0
- `pnpm check:platform-object-tenancy-census` :: exit 0
- `pnpm check:pm-changeset-deadline-census` :: exit 0
- `pnpm check:published-files` :: exit 0
- `pnpm check:query-options-erasure` :: exit 0
- `pnpm check:refd-timer-probe` :: exit 0
- `pnpm check:registry-log-declared` :: exit 0
- `pnpm check:rest-log-spy-declared` :: exit 0
- `pnpm check:slot-lookup` :: exit 0
- `pnpm check:sourcemap-no-sources-content` :: exit 0
- `pnpm check:swallow-census-controls` :: exit 0
- `pnpm check:system-context-census` :: exit 0
- `pnpm check:test-source-alias` :: exit 0
- `pnpm check:tier-file-adoption` :: exit 0
- `pnpm check:type-check-coverage` :: exit 0
- `pnpm check:type-check-debt` :: exit 0
- `pnpm check:undeclared-dep-imports` :: exit 0
- `pnpm check:watch-hint-literal` :: exit 0
- `pnpm check:where-matcher` :: exit 0
</details>
## Human Minimum
- [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic.
(No `packages/spec` change.)
- [x] 2. Every name I added uses the casing for its kind.
- [x] 3. No formatter ran; `pnpm lint` passes (scoped run above; the
repo-wide run is CI's).
- [x] 4. One issue, one branch off an up-to-date `main`, one pull
request. (Four cards, one PR, by the dispatch order.)
- [x] 5. A change that publishes carries a changeset, and both
declaration lines are filled in here and in the changeset.
- [x] 6. `packages/spec` touched: not touched.
- [x] 7. No hand edits to `content/docs/references/`,
`packages/*/CHANGELOG.md` or `content/docs/releases/`.
- [x] 8. Title, body and comments are in English (the 维护者速读 block is the
dispatch contract's).
- [x] The *Before Submitting* list in CONTRIBUTING.md is done.
---
_Generated by [Claude
Code](https://claude.ai/code/session_01FEkQ5kbUJTTWAxjV9nqr1P)_
🤖 Generated with [Claude Code](https://claude.com/claude-code)
https://claude.ai/code/session_01FEkQ5kbUJTTWAxjV9nqr1P
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent 36fa4ff commit c01690e
7 files changed
Lines changed: 593 additions & 46 deletions
File tree
- .changeset
- packages
- rest/src
- services/service-analytics/src
- __tests__
- strategies
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
153 | 153 | | |
154 | 154 | | |
155 | 155 | | |
156 | | - | |
| 156 | + | |
157 | 157 | | |
158 | 158 | | |
159 | 159 | | |
| |||
225 | 225 | | |
226 | 226 | | |
227 | 227 | | |
228 | | - | |
229 | | - | |
230 | | - | |
231 | | - | |
232 | | - | |
233 | | - | |
234 | | - | |
| 228 | + | |
| 229 | + | |
| 230 | + | |
| 231 | + | |
| 232 | + | |
| 233 | + | |
| 234 | + | |
| 235 | + | |
| 236 | + | |
| 237 | + | |
| 238 | + | |
235 | 239 | | |
236 | 240 | | |
237 | 241 | | |
238 | | - | |
| 242 | + | |
| 243 | + | |
239 | 244 | | |
240 | 245 | | |
241 | 246 | | |
| |||
256 | 261 | | |
257 | 262 | | |
258 | 263 | | |
| 264 | + | |
| 265 | + | |
| 266 | + | |
| 267 | + | |
| 268 | + | |
259 | 269 | | |
260 | 270 | | |
261 | 271 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
16 | 16 | | |
17 | 17 | | |
18 | 18 | | |
19 | | - | |
20 | | - | |
21 | | - | |
22 | | - | |
23 | | - | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
24 | 26 | | |
25 | 27 | | |
26 | 28 | | |
| |||
182 | 184 | | |
183 | 185 | | |
184 | 186 | | |
185 | | - | |
| 187 | + | |
186 | 188 | | |
187 | 189 | | |
188 | 190 | | |
189 | 191 | | |
190 | 192 | | |
191 | | - | |
192 | | - | |
193 | | - | |
| 193 | + | |
| 194 | + | |
| 195 | + | |
| 196 | + | |
| 197 | + | |
| 198 | + | |
194 | 199 | | |
195 | 200 | | |
196 | 201 | | |
| |||
226 | 231 | | |
227 | 232 | | |
228 | 233 | | |
229 | | - | |
| 234 | + | |
230 | 235 | | |
231 | 236 | | |
232 | 237 | | |
233 | | - | |
| 238 | + | |
234 | 239 | | |
| 240 | + | |
| 241 | + | |
235 | 242 | | |
236 | 243 | | |
0 commit comments