Repository navigation
Commit 0b82391
Fixes #21437
Clause-②: no (narrowing)
Dispatched by the PM claim `5961286080` (PM loop round 2,
`domain:services` seat 2), on triage's grade `5957198328` as amended by
`5958146715`. Session `session_01DiCSbmJrkzNhuEAier4VoJ`.
`inferMeasure` minted the row wildcard `'*'` for an empty prefix (`_sum`
became `{ type: 'sum', sql: '*' }`), and it passed `*`, `*_sum` and the
empty spelling through verbatim. The member-shape gate admits `'*'` as a
column reference, so `SUM(*)` / `AVG(*)` / `COUNT(DISTINCT *)` / `SUM()`
reached the database and `POST /api/v1/analytics/query` answered `500
DATABASE_ERROR` on both strategies. The mint now admits `'*'` only for
the bare `count`. It refuses a source that names no field with
`INVALID_FIELD` / 400, naming the spelling the caller sent, before any
statement is built.
## What changes (`@objectstack/service-analytics`,
`src/analytics-service.ts` only)
- **`INFERRED_MEASURE_SUFFIXES`.** The suffix list `inferMeasure`
iterates is hoisted to one module-level constant. It is exported from
the module only, not from the package index. The enumeration pin
iterates it, so a suffix added later is pinned when it lands.
- **`inferMeasure(key, spelling)`.** For any key but `count`, the source
is the part before the matched suffix, or the whole key when no suffix
matches. A source that is empty or `'*'` is refused. No other path
changes: a non-empty source is minted exactly as before.
- **The envelope.** The refusal is built by `invalidMemberError`, the
constructor the mint's dotted-measure refusal already uses. It carries
`code: INVALID_FIELD`, `status: 400`, `member` (the spelling as sent,
with any `CUBE.` qualifier), `param: 'measures'` and `cube`. That is the
code and status of the missing-field refusal (`assertMeasureFields`).
There is no `field`, because there is no field to name.
- **Both mint sites** pass the request's own entry. They are the ad-hoc
mint in `inferCubeFromQuery` and the augmentation loop in `ensureCube`.
Both run inside `ensureCube`, ahead of `assertCallerMembersResolvable`
and of strategy selection, on `query()` and on the dry run
`generateSql()`.
- **`inferredCallerMeasureSql`**, the gate's input, is exported from the
module only, for the by-construction pin.
- **Unchanged, per the amended ruling:** the gate's `'*'` pass-through.
There is no local copy of `rowWildcardOutsideCount`, no spec export, no
strategy edit and no `filter-normalizer.ts` edit (the file #21448
holds).
## Measured at the door: `POST /api/v1/analytics/query`
The probe used the runtime dispatcher's composition from
`packages/runtime/src/analytics-json-dimension-door.test.ts`:
`AnalyticsServicePlugin` over a real `ObjectQL` engine and `SqlDriver`
(SQLite), with the dispatcher-plugin route. The ObjectQL cells narrow
`queryCapabilities` to the engine-aggregate path. There are three rows,
amounts 100 / 300 / 1000. The authored cube declares `count` and
`amount_total` only. The probe file was throwaway and is not committed.
| spelling | `origin/main` `713b0fa76`: native SQL | `713b0fa76`:
ObjectQL | this branch: both strategies |
|---|---|---|---|
| `_sum`, `_avg`, `_average`, `_min`, `_max` (ad hoc and authored, bare
and `CUBE.`-qualified) | 500 `DATABASE_ERROR`, rawSql+1 | 500
`DATABASE_ERROR`, aggregate+1 | 400 `INVALID_FIELD`, no read |
| `_count_distinct` | 500 `DATABASE_ERROR`, rawSql+1 | 400
`INVALID_QUERY`, aggregate+1 | 400 `INVALID_FIELD`, no read |
| `*`, `*_sum`, `*_avg`, `CUBE.*` | 500 `DATABASE_ERROR`, rawSql+1 | 500
`DATABASE_ERROR`, aggregate+1 | 400 `INVALID_FIELD`, no read |
| `*_count_distinct` | 500 `DATABASE_ERROR`, rawSql+1 | 400
`INVALID_QUERY`, aggregate+1 | 400 `INVALID_FIELD`, no read |
| `''` (empty spelling) | 500 `DATABASE_ERROR`, rawSql+1 | 500
`DATABASE_ERROR`, aggregate+1 | 400 `INVALID_FIELD`, no read |
| `CUBE.` (qualifier alone) | 403 `PERMISSION_DENIED`, no read | 403
`PERMISSION_DENIED`, no read | 400 `INVALID_FIELD`, no read |
| control `count` | 200, 3 | 200, 3 | 200, 3 |
| control `amount_sum` | 200, 1400 | 200, 1400 | 200, 1400 |
| control `amount_total` (authored) | 200, 1400 | 200, 1400 | 200, 1400
|
| `_`, `__sum` (prefix `_` is a field-shaped name) | 400 `INVALID_FIELD`
(missing field `_`) | same | unchanged |
Every refused cell's message names the spelling sent, for example
"Measure '_sum' on cube 'X' names no field to aggregate: nothing
precedes the suffix '_sum'".
## Scope: one rule, wider than the card's title (declared)
- **The row wildcard source (`*`, `*_SUFFIX`, `CUBE.*`).** The card
names the empty prefix. The added pin ("for every caller-named spelling,
a `'*'` reaches the gate only together with `count`") cannot hold unless
the mint also refuses a `'*'` source: `*` and `*_sum` mint `'*'` under
`sum`, verbatim. That was measured as 500 above. One condition, source
`'*'`, covers it. This is the card's own family (the row wildcard under
a non-`count` aggregate) and needs no second rule.
- **The empty source with no suffix (`''`, and `CUBE.` after the
qualifier strip).** These are bounded in-place fixes, and all four
conditions hold.
1. It is the same defect class: a caller spelling minted into an
aggregate over a source that names no field. `''` answered 500 on
`main`.
2. The fix is mechanical: the same `source === ''` condition.
3. The file is held by no other claim (the claim's file surface).
4. It is the same pin file and gate family, with no new verification
surface.
Moved answer, declared in the changeset: `CUBE.` was a 403 from the
member-shape gate and is now the mint's 400, since it names no field
either.
- **Authored members are not this card's.** `CubeSchema` admits any
measure key (`z.record(z.string(), MetricSchema)`). Measured at the spec
`dist` built from `713b0fa76`: a cube declaring measures `_sum` and `*`
parses. `DatasetSchema` refuses a dataset measure named `_sum`
(`invalid_format`, snake_case starting with a letter). A cube that
DECLARES `_sum` hits its own member and never reaches the mint. A pin
serves it (1400).
## Pins: `src/__tests__/caller-measure-no-field-door.test.ts` (new)
The pins use the plugin's own composition over a real `ObjectQL` engine
and `SqlDriver` (SQLite), both auto-bridges live, on the native and
ObjectQL strategies. They follow the precedent of
`cube-measure-field-type-door.test.ts`.
- **Enumeration.** Every suffix in `INFERRED_MEASURE_SUFFIXES` is tested
with an empty prefix, bare and `CUBE.`-qualified, on both strategies, on
an ad-hoc cube and on an authored cube that does not declare it. Each is
refused with `INVALID_FIELD` / 400, `member` equal to the spelling,
`param: 'measures'`, `cube`, no `field`, and the message naming the
spelling. Each run has zero raw statements and zero engine aggregates.
- **Other no-field sources.** `*`, `*` with every suffix, `''`, `CUBE.*`
and `CUBE.` get the same refusal, with no read.
- **Controls.** The bare `count` returns 3, and its dry-run statement is
`COUNT(*)`. `amount_sum` returns 1400 (ad hoc and authored). The
authored `amount_total` returns 1400. A declared `_sum` member is
served, 1400.
- **Dry-run door.** `generateSql` refuses every empty-prefix suffix the
same way.
- **By construction (the added pin).** Over a generated corpus
(qualifier `''` / `CUBE.` / `other.` × prefix `''`, `*`, `**`, a space,
`_`, `amount`, `count` × tail `''` or each suffix),
`inferredCallerMeasureSql` either refuses with the envelope or returns
null or a string. A `'*'` comes back only for `count` and `CUBE.count`,
which `inferMeasure` types `count`. The pin also asserts that the corpus
exercised both arms.
The pins stay in `service-analytics`. `AnalyticsService.query()` /
`generateSql()` are what the dispatcher's `/analytics/query` and
`/analytics/sql` routes call one-to-one. The dispatcher carries a thrown
`code` / `status` to the wire, and the same `INVALID_FIELD` / 400
crossing is already pinned at the route by
`packages/runtime/src/analytics-json-dimension-door.test.ts`. The
throwaway probe above measured the wire answers on this branch. No
runtime file is added, so there is no cross-lane addition.
## Ablations (from the committed state, `node
scripts/ablation-replace.mjs` in wrap mode, restore proven: blob equals
HEAD and `git diff HEAD` is empty)
The subject is imported from `src` (`../analytics-service.js`), so no
`dist` sits on the path.
- **A1: the predecessor mint restored.** The throw is replaced with
`return { label: key, type, sql: source || '*' };`. Predicted: the 8
refusal tests, the 2 dry-run tests and the by-construction pin go red,
and the 9 controls stay green. Observed: **11 failed, 9 passed**. The
enumeration cells answer `DATABASE_ERROR` again (and `INVALID_QUERY` for
`_count_distinct` on ObjectQL), the dry run resolves a statement, and
the by-construction pin reports that `''` reaches the gate as `'*'` but
is not `count`.
- **A2: only the wildcard arm dropped.** The condition `source === '' ||
source === '*'` becomes `source === ''`. Predicted: the empty-prefix
enumeration and the dry run stay green, and the 4 other-no-field tests
and the by-construction pin go red. Observed: **5 failed, 15 passed**,
with "`*` reaches the gate as `'*'` but is not count". So the added pin
catches what the empty-prefix enumeration alone does not.
## Verification (at `b49aba455`, after merging `origin/main`
`49524f690`)
- `pnpm --filter @objectstack/service-analytics typecheck`: clean. `tsc
--listFiles` includes the new test file.
- `pnpm --filter @objectstack/service-analytics test` (the full script):
**172 files passed, 3931 tests passed, 183 skipped**.
- `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack
--commands` derived 64 commands, and all 64 were run. 63 exited 0 on
their first run. `pnpm check:dual-build-cjs-loads` first exited 3 with
`PREREQUISITE NOT MET` (some packages had no `dist/`). After a full
`turbo run build` (72 tasks, 71 cache hits) it exited 0. `--ran` with
every exit code recorded: "64 derived, 64 run, 0 NOT-MEASURED, 0 UNRUN".
- `check:adr-0087-registration` judged the changeset
`[BREAKING+bang+clause-②-narrowing]`, `not-required
(no-migration-prescription)`. `check:changeset-no-major` reports no
`major`, and `check:empty-changeset` reports no empty-frontmatter
changeset and no modified one.
- Lint, narrowed and declared as such. eslint `--no-inline-config
--format json` over the two touched `.ts` files reports 2 files, 0
errors and 0 warnings. `eslint.config.mjs` never enables type-aware
linting (no `parserOptions.project`), so this diff cannot move a verdict
on an untouched file. The repo-wide `pnpm lint` is CI's.
- CI's own jobs (Test Core shards, Dogfood, Build Core, the type-check
lanes) were not measured locally.
## Changeset
`.changeset/21437-analytics-measure-names-no-field.md` declares
`@objectstack/service-analytics`: `minor` with the BREAKING banner, the
`Clause-②: no (narrowing)` line, before and after cells, the one-line
fix, and one ADR-0087 disposition (`not-required
(no-migration-prescription)`, every other category ruled out on facts).
Its twin precedent is #21431's
`.changeset/21409-analytics-row-wildcard-count-only.md`, the
authored-position half of the same rule. It is the same
`minor`-under-launch-window shape with the BREAKING banner and the
`(narrowing)` arm. That one registers a D3 entry because stored
documents need a prescription. This one has no stored shape, so it takes
the `no-migration-prescription` disposition, as the sibling
analytics-door narrowings do (`21267-analytics-order-key-selected.md`,
`21426-native-number-comparand.md`).
## Docs
`content/docs/api/data-api.mdx` ("How to spell a measure") already
states the contract this enforces: the bare `count`, or one of the
object's own field names plus a suffix. The change makes no sentence
there false, so it is untouched. A `skills/**` grep for measure
spellings finds only field-prefixed ones (`amount_sum`, `total_sum`,
`revenue_sum`). None is an empty prefix or `'*'`.
## Acceptance notes
- **Docs drift, not caused here.** `inferMeasure` also strips
`_average`, and with no suffix it sums the whole key. `data-api.mdx`
lists neither. Nothing was made false, so this is noted only (carrier:
none).
- **The console adapter.** objectui's analytics adapter, at the pinned
`.objectui-sha` `89cad75d5`, composes a measure as the value field, an
underscore and the function. A widget with an empty value field would
post `_sum`. That adapter classified the old 500 as `unknown` and
answered with its client-side `aggregateViaFind`. It classifies the new
400 as `rejected` and throws `AnalyticsQueryRejectedError`. This is the
intended direction (loud over plausible numbers), and the changeset
states it. Whether any shipped widget reaches an empty value field was
not measured.
- **The gate's empty-measure skip.** The `measure === ''` skip in
`assertCallerMembersResolvable` no longer sees `''`, because the mint
refuses it first. It is left as is, and the gate is untouched per the
ruling.
- **No dialect cell.** The pin file has no PostgreSQL cell. The refusal
happens before any statement, so it is dialect-free.
---
_Generated by [Claude
Code](https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ)_
---------
Co-authored-by: Claude <noreply@anthropic.com>
1 parent 6e33b67 commit 0b82391
3 files changed
Lines changed: 423 additions & 19 deletions
File tree
- .changeset
- packages/services/service-analytics/src
- __tests__
| 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 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
Lines changed: 298 additions & 0 deletions
| 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 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
| 69 | + | |
| 70 | + | |
| 71 | + | |
| 72 | + | |
| 73 | + | |
| 74 | + | |
| 75 | + | |
| 76 | + | |
| 77 | + | |
| 78 | + | |
| 79 | + | |
| 80 | + | |
| 81 | + | |
| 82 | + | |
| 83 | + | |
| 84 | + | |
| 85 | + | |
| 86 | + | |
| 87 | + | |
| 88 | + | |
| 89 | + | |
| 90 | + | |
| 91 | + | |
| 92 | + | |
| 93 | + | |
| 94 | + | |
| 95 | + | |
| 96 | + | |
| 97 | + | |
| 98 | + | |
| 99 | + | |
| 100 | + | |
| 101 | + | |
| 102 | + | |
| 103 | + | |
| 104 | + | |
| 105 | + | |
| 106 | + | |
| 107 | + | |
| 108 | + | |
| 109 | + | |
| 110 | + | |
| 111 | + | |
| 112 | + | |
| 113 | + | |
| 114 | + | |
| 115 | + | |
| 116 | + | |
| 117 | + | |
| 118 | + | |
| 119 | + | |
| 120 | + | |
| 121 | + | |
| 122 | + | |
| 123 | + | |
| 124 | + | |
| 125 | + | |
| 126 | + | |
| 127 | + | |
| 128 | + | |
| 129 | + | |
| 130 | + | |
| 131 | + | |
| 132 | + | |
| 133 | + | |
| 134 | + | |
| 135 | + | |
| 136 | + | |
| 137 | + | |
| 138 | + | |
| 139 | + | |
| 140 | + | |
| 141 | + | |
| 142 | + | |
| 143 | + | |
| 144 | + | |
| 145 | + | |
| 146 | + | |
| 147 | + | |
| 148 | + | |
| 149 | + | |
| 150 | + | |
| 151 | + | |
| 152 | + | |
| 153 | + | |
| 154 | + | |
| 155 | + | |
| 156 | + | |
| 157 | + | |
| 158 | + | |
| 159 | + | |
| 160 | + | |
| 161 | + | |
| 162 | + | |
| 163 | + | |
| 164 | + | |
| 165 | + | |
| 166 | + | |
| 167 | + | |
| 168 | + | |
| 169 | + | |
| 170 | + | |
| 171 | + | |
| 172 | + | |
| 173 | + | |
| 174 | + | |
| 175 | + | |
| 176 | + | |
| 177 | + | |
| 178 | + | |
| 179 | + | |
| 180 | + | |
| 181 | + | |
| 182 | + | |
| 183 | + | |
| 184 | + | |
| 185 | + | |
| 186 | + | |
| 187 | + | |
| 188 | + | |
| 189 | + | |
| 190 | + | |
| 191 | + | |
| 192 | + | |
| 193 | + | |
| 194 | + | |
| 195 | + | |
| 196 | + | |
| 197 | + | |
| 198 | + | |
| 199 | + | |
| 200 | + | |
| 201 | + | |
| 202 | + | |
| 203 | + | |
| 204 | + | |
| 205 | + | |
| 206 | + | |
| 207 | + | |
| 208 | + | |
| 209 | + | |
| 210 | + | |
| 211 | + | |
| 212 | + | |
| 213 | + | |
| 214 | + | |
| 215 | + | |
| 216 | + | |
| 217 | + | |
| 218 | + | |
| 219 | + | |
| 220 | + | |
| 221 | + | |
| 222 | + | |
| 223 | + | |
| 224 | + | |
| 225 | + | |
| 226 | + | |
| 227 | + | |
| 228 | + | |
| 229 | + | |
| 230 | + | |
| 231 | + | |
| 232 | + | |
| 233 | + | |
| 234 | + | |
| 235 | + | |
| 236 | + | |
| 237 | + | |
| 238 | + | |
| 239 | + | |
| 240 | + | |
| 241 | + | |
| 242 | + | |
| 243 | + | |
| 244 | + | |
| 245 | + | |
| 246 | + | |
| 247 | + | |
| 248 | + | |
| 249 | + | |
| 250 | + | |
| 251 | + | |
| 252 | + | |
| 253 | + | |
| 254 | + | |
| 255 | + | |
| 256 | + | |
| 257 | + | |
| 258 | + | |
| 259 | + | |
| 260 | + | |
| 261 | + | |
| 262 | + | |
| 263 | + | |
| 264 | + | |
| 265 | + | |
| 266 | + | |
| 267 | + | |
| 268 | + | |
| 269 | + | |
| 270 | + | |
| 271 | + | |
| 272 | + | |
| 273 | + | |
| 274 | + | |
| 275 | + | |
| 276 | + | |
| 277 | + | |
| 278 | + | |
| 279 | + | |
| 280 | + | |
| 281 | + | |
| 282 | + | |
| 283 | + | |
| 284 | + | |
| 285 | + | |
| 286 | + | |
| 287 | + | |
| 288 | + | |
| 289 | + | |
| 290 | + | |
| 291 | + | |
| 292 | + | |
| 293 | + | |
| 294 | + | |
| 295 | + | |
| 296 | + | |
| 297 | + | |
| 298 | + | |
0 commit comments