Uh oh!
There was an error while loading. Please reload this page.
fix(service-analytics): resolve {current_user_id} on the direct analytics query path, at parity on both compiled surfaces - #12376
Conversation
…tics query path, at parity on both compiled surfaces The direct door (AnalyticsService.query / generateSql) resolved no filter placeholder itself: the ObjectQL strategy's engine bridge resolved downstream, but NativeSQLStrategy compiled a raw SELECT ... WHERE and bound the literal text -- one vocabulary, two verdicts, and the losing verdict was a silent 0 for every viewer of every user-scoped widget. The #10298 dataset-scope channel had the same hole one layer down: strategies received the registry's unresolved filter copy and ANDed it in beside the executor's resolved one, selecting nothing. Both close at one seam: query()/generateSql() expand `where`, `timeDimensions[].dateRange`, and the per-request dataset-scope getter with one instant per call, before strategy selection, via the platform's one token evaluator (resolveFilterTokens, @objectstack/core). Unresolvable placeholders refuse with FILTER_TOKEN_UNKNOWN / FILTER_TOKEN_UNRESOLVED (400) instead of charting zero. Per-viewer contrast pinned on both surfaces and end-to-end on a real database. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UQgPSniH1GFM9ZDeGyuGUa
…rity row fixture check:where-matcher flagged the fixture matcher as combinator-blind: $or would have been read as a field name and silently matched nothing. Refuse every combinator and operator the fixture does not model, per the gate's convention. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UQgPSniH1GFM9ZDeGyuGUa
📓 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 328601bd9985124c47746ae27f74e171bd13fdf5 && git checkout 328601bd9985124c47746ae27f74e171bd13fdf5
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin d516483f655681414796f244a46f903df2bf82f2 e3011f6c45c70d539e4d590ca91195dc1b935fee && git checkout -B drift-repro d516483f655681414796f244a46f903df2bf82f2 && git merge --no-ff e3011f6c45c70d539e4d590ca91195dc1b935fee
node scripts/docs-audit/affected-docs.mjs --json d516483f655681414796f244a46f903df2bf82f2
|
os-trump
commented
Aug 25, 2026
Contract-review verdict: PASS (#12230)Reviewed by the contract-review chain at Reviewed: the full diff (the service seam, both test files, the changeset), the card's triage constraints and claim, the dev report, and the seat's ACCEPT — including the #3594 sentence the seat put in front of this review. The Clause-② surface, weighed with "presentation scope, never an access boundary" in hand: this PR implements the frozen vocabulary's own declared semantics at parity — Verified in the seam patch: one evaluator imported from The load-bearing pin does what the brief demanded and more: per-viewer contrast in both directions at three depths (bound params, the aggregate bridge, real rows on a real database, both doors), the #10298 double-predicate hole pinned by name, refusals asserting The premise correction is a good run: the card's tarball-measured zero was stale; the dev found the two narrower live holes (the direct door, and the #10298 channel invisible to presence-only assertions) and measured both red before fixing. The open trailer-spelling question is answered A by standing practice (session URL, no model names) — already what these commits carry. Landing completed by the chain in the same stroke per the maintainer's standing instruction (2026-08-25, verbatim: 「审核通过你应该直接负责合并吧,还要等原始的项目经理吗」): clearing Generated by Claude Code |
Uh oh!
There was an error while loading. Please reload this page.
Fixes#12230
Premise re-derivation — the tree moved since 17.1.0, the defect narrowed but survived
The card's zero-hit grep was measured on the published
@objectstack/service-analytics@17.1.0tarball. Onorigin/maintoday, framework#3582's evaluator (resolveFilterTokens,@objectstack/core) already covers the ObjectQL engine andDatasetExecutor(the dashboard dataset door). Two holes survived, both the exact "one vocabulary, two verdicts" shape the card names — and both were measured red before fixing (dissolution runs below):POST /analytics/query→AnalyticsService.query, andgenerateSqlfor/analytics/sql) resolved nothing itself. The ObjectQL strategy resolves downstream in the engine;NativeSQLStrategycompiles a rawSELECT … WHEREand bound the literal —owner = '{current_user_id}'matches no row, so every user-scoped widget on a SQL driver rendered a silent0for every viewer.aggregate: 'count'ignoresfield, and/api/v1/analytics/querydrops every per-measurefilter— the SQL isCOUNT(*)for all of them #10298 dataset-scope channel handed strategies the registry's compiled dataset filter — shared across requests, never resolved. On the dashboard door the executor resolves its own copy intowhere, and the strategy then ANDed the unresolved twin:owner = $viewer AND owner = '{current_user_id}'selects nothing, even though the presence-only assertions in the existing token test stayed green ([ 'usr_1', '{current_user_id}' ]— both bound, verbatim from the dissolution run). The "redundant and idempotent" reasoning that justified the conjunct holds only for token-free filters. The same channel zeroed measure-scoped filters through theCASE WHENarm.The fix — option 1 (parity substitution), one seam
AnalyticsService.query()andgenerateSql()now expand every filter placeholder before strategy selection, with one instant per call, via the platform's one evaluator (resolveFilterTokens— consumed from@objectstack/core, not redefined; zeropackages/specchanges):whereandtimeDimensions[].dateRange;callCtx), so the Measure definitions are not fully compiled:aggregate: 'count'ignoresfield, and/api/v1/analytics/querydrops every per-measurefilter— the SQL isCOUNT(*)for all of them #10298 channel hands strategies the viewer's filter and measure filters, never the registry's literal copy.Copy-on-write throughout: caller-owned query objects and registry entries are never mutated, so no request's user id is ever baked into a shared definition. Token-free trees pass through by reference (the resolver's cheap pre-pass), so internal queries cost nothing.
The refusal arm comes with the same seam (the card's option 2, for the cases substitution cannot serve): an unknown spelling refuses
FILTER_TOKEN_UNKNOWN/ 400 with the near-miss suggestion, and a vocabulary token with no value ({current_user_id}on an unauthenticated request) refusesFILTER_TOKEN_UNRESOLVED/ 400 — neverIS NULL, never a plausible zero. Both asserted oncodeandstatus(ADR-0112).Both compiled surfaces, named, with what each does with the token
NativeSQLStrategy) — before: bound the literal text as a parameter in four positions (query.where,timeDimensions[].dateRange, the Measure definitions are not fully compiled:aggregate: 'count'ignoresfield, and/api/v1/analytics/querydrops every per-measurefilter— the SQL isCOUNT(*)for all of them #10298 dataset-scope conjunct, the measure-filterCASE WHENpredicate); zero rows, silently. After: receives only resolved values from the service seam. Pinned by reading the bound params and by executing against a real (sql.js) database.ObjectQLStrategy→ theexecuteAggregatebridge toengine.aggregate) — before:query.whereresolved downstream in the engine (framework#3582), but the Measure definitions are not fully compiled:aggregate: 'count'ignoresfield, and/api/v1/analytics/querydrops every per-measurefilter— the SQL isCOUNT(*)for all of them #10298 dataset-scope filter merged in by the strategy reached the bridge unresolved on fake/delegate bridges, and behaviour depended on which strategy won selection. After: receives already-resolved trees — the engine's own resolution becomes a no-op second pass, and the two surfaces answer identically by construction. Pinned by reading the filter handed to the bridge and by row-level evaluation.No half was shipped: the same seam feeds both strategies (and any custom or fallback-delegate strategy), and
queryDataset's executor path keeps its own resolution for the preview evaluator it alone feeds.The load-bearing pin — per-viewer contrast, both directions
This writes a viewer identity into a SQL
WHERE; the failure that matters is resolving to the wrong viewer.query-filter-tokens.test.tsruns the same saved filter as two users against one service instance and asserts both directions at three depths: bound params (A's call bindsusr_aand notusr_b, B's the reverse, the literal nowhere), the aggregate bridge's filter, and real rows on a real database (sql.js: A sees A's 2 high-priority cases and none of B's; B sees B's 1 low-priority case and none of A's — dashboard door and direct door both). The existing registry-mutation pin (dataset-filter-tokens.test.ts) already guards the resolved-once-at-boot shape; it gained the absence assertions that the double-predicate hole was slipping past.Verification (all runs at head
e3011f6c4after the rebase ontoe3430a2cb)pnpm --filter @objectstack/service-analytics test— 82 files, 1803 tests, all green (verify-lock VERDICT command-exit 0).tsc --noEmiton the package: exactly the 10 pre-existing ledger-debt errors (check:type-check-coverageledger count 10; same files and classes as the ledger note) — zero new.git checkout origin/main -- src/analytics-service.ts(mutation proven on disk: anchored grepresolveQueryTokens→ 0 hits,git diff --stat HEAD= 116 lines reversed in that one file), then the two token test files → 16 of 19 red, e.g.AssertionError: expected [ 'usr_1', '{current_user_id}' ] to not include '{current_user_id}'andexpected [ '{current_user_id}', …(1) ] to include 'usr_a'; the 3 green survivors are the pre-existing executor-half pins, correctly untouched. Restored viagit checkout HEAD -- <path>, disk == index == HEAD verified (git status --porcelainempty,git diff HEADempty, anchor grep = 3), re-run → 19/19 green....@objectstack/service-analytics= downstream consumers (rest, runtime, cli, verify, qa/dogfood):turbo run build --filter='...@objectstack/service-analytics'→ 67 tasks, all successful; runtime's analytics withhold test 10/10 and rest's 7 analytics route/gate files 85/85 green.node scripts/pm/dispatch-gates.mjs, no paths passed — change set derived from merge basee3430a2cb; re-derived after the rebase, list unchanged): all 17 path-matched families green (check:changeset-gate-self-tests,check:objectui-changeset,check:page-declaration-shape,check:published-files,check:slot-lookup,check:test-source-alias,check:type-source-resolution,check:cross-package-test-inputsboth spellings,check-adr-0087-registration,check-changeset-no-major,check-ci-filter-parity,check-empty-changeset,check-plugin-teardown-shape,check-affected-docs,release-rehearsal-clone --self-test) plus the convention-triggered families for new test files:check:engine-double-contract,check:where-matcher(which caught the fixture matcher being combinator-blind — fixed to refuse, second commit),check:query-options-erasure,check:type-check-coverage— all green.check-drift-commentneeds PR context and runs in CI.check:type-check-debt --re-measurere-runs tsc per ledger entry; the only entry whose program compiles files in this diff is@objectstack/service-analytics(population read from the gate's own ledger;check:cross-package-test-inputsgreen proves no other package's tests read these files; consumers see only the built.d.ts, whose exported shape is unchanged and whose 67-task dts builds passed) — re-measured at head: exactly 10, the ledger count. ② Repo-scan eslint: targeted run of the 3 edited TS files under the repo's singleeslint.config.mjs→--format jsonreports 3 files linted, 0 errors 0 warnings; the config never enables type-aware linting for any file (its own §"noparserOptions.project" comment), so verdicts are per-file and this diff cannot move any untouched file's result; the 4th changed file is a.changeset/*.md, outside eslint's population and covered by the changeset gates above.Changeset
minorfor@objectstack/service-analytics(.changeset/analytics-current-user-token.md): a widget that silently rendered 0 now either works or refuses. Notmajor.Scope notes
packages/specchanges;{current_user_id}is consumed as the frozen vocabulary PR fix(spec,lint): gate unresolvable filter placeholders; freeze the {current_user_id} vocabulary (#3574) #3594 declared. Per that PR's own framing, context tokens stay presentation scope, never an access boundary — RLS/read-scope compilation is untouched (itscurrent_user.*CEL root is a different mechanism and was not conflated).packages/plugins/plugin-auth/**untouched. No cross-repo writes; downstream trackingobjectstack-ai/hotcrm#510is the PM's follow-on after merge.Generated by Claude Code
Generated by Claude Code