Skip to content

Commit 4ec5057

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-21000-retire-expression-metric-types
# Conflicts: # packages/spec/src/data/analytics.zod.ts
2 parents 228a253 + b793010 commit 4ec5057

17 files changed

Lines changed: 1798 additions & 71 deletions
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
---
2+
'@objectstack/metadata-protocol': patch
3+
---
4+
5+
fix(metadata-protocol): a view container saved for an object another package ships no longer replaces that package's views or its default
6+
7+
Clause-②: no
8+
9+
- **What was wrong.** A runtime view container expands each member to `<object>.<key>`. A `list` that names no key becomes `<object>.default`, a `form` becomes `<object>.form`, and every member that names a key uses that key. Saved under another name, in another package or in none, for an object a code package ships, those expansions replaced that package's views of the same names on `GET /api/v1/meta/view?object=<object>`. The replacements were still stamped with the shipping package's `_packageId` and `_provenance: 'package'`.
10+
- On an environment-scoped kernel, the by-name read `GET /api/v1/meta/view/<name>` kept the packaged view, so the two reads disagreed.
11+
- On an unscoped kernel, the by-name read served the replacement too, for a container saved into a package or environment-wide.
12+
- The container's own default kept `isDefault: true`. It either replaced the object's default view or stood beside it as a second list default.
13+
- **What it does now.** For an object a code package ships, a container that belongs to another package, or to none, expands every member under its own name:
14+
- a `list` that names no key becomes `<object>.<container name>`;
15+
- every other member becomes `<object>.<container name>.<key>`. That covers a `list` that names its key, each `listViews` and `formViews` entry, and `form`.
16+
17+
None of these views carries `isDefault`. Every name the shipping package serves answers its packaged view on both reads, unchanged, and the only `isDefault` views the object lists are the shipping package's.
18+
- **One exception.** When the shipping package itself serves `<object>.<container name>` (a container named after one of that package's keys), the container's default list becomes `<object>.<container name>.<container name>` instead.
19+
- **A container with no name of its own** expands nothing on such an object.
20+
- **What these views carry.** The container's own package as `_packageId` (none for a package-less container), and no other package's `_provenance` or protection envelope.
21+
- **What stays.** Three kinds of container expand exactly as before, `isDefault` included:
22+
- a container bound to the package that ships the object;
23+
- a package-less overlay of that package's own container, saved under that container's name;
24+
- a container on an object no code package ships.
25+
26+
A write to `<object>.default` by its own name still overrides it on both reads.
27+
- **What changes for a caller.** Such a container's views are now served under new names:
28+
- its default list as `<object>.<container name>`, instead of `<object>.default`;
29+
- each keyed member as `<object>.<container name>.<key>`, instead of `<object>.<key>`.
30+
31+
A navigation `viewName` or a form-action `target` that used an old name to reach one of these views now reaches the shipping package's view. Use the new name instead.
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
---
2+
'@objectstack/spec': minor
3+
---
4+
5+
feat(spec)!: the analytics row wildcard `'*'` is admitted only where a `count` consumes it — a cube or dataset measure over `'*'` under any other aggregate, and a cube dimension over `'*'`, are refused at parse (#21409)
6+
7+
Clause-②: no (narrowing)
8+
9+
**BREAKING** — shipped as `minor` under the launch-window convention
10+
(`check-changeset-no-major` refuses `major` until GA; breaking-ness is carried by
11+
this banner, the `(narrowing)` arm above and the ADR-0087 disposition below,
12+
never by the level).
13+
14+
`'*'` is the row wildcard: what a `count` aggregates (`COUNT(*)`), reading no
15+
field value. It is now admitted in exactly one place, a measure that counts:
16+
17+
- `MetricSchema.sql` — a cube measure's `sql` — admits `'*'` under
18+
`type: 'count'` only; under any other `type` it is refused at `sql`
19+
(code `custom`).
20+
- `DatasetMeasureSchema.field` — an ADR-0021 dataset measure's `field` — admits
21+
`'*'` under `aggregate: 'count'` only; under any other aggregate, or on a
22+
measure with no aggregate (a `derived` one), it is refused at `field`
23+
(code `custom`). A count may still omit `field`.
24+
- `DimensionSchema.sql` — a cube dimension's `sql` — never admits `'*'`
25+
(code `invalid_format`): it takes the column path without the wildcard arm,
26+
the pattern a dataset dimension's `field` already takes.
27+
28+
Each refusal names the slot and the aggregate the author wrote, and prescribes
29+
the two ways out: a `count`, or a column. A column or a relationship path parses
30+
byte-identically to before on every slot, and so does a `count` over `'*'`.
31+
32+
Why: no aggregate but `count` has a column to read over `'*'`, and a dimension
33+
has no aggregate at all, yet the contract admitted the wildcard on any measure
34+
and on a cube dimension, and the analytics strategies sent it to the database as
35+
written. Measured at `POST /api/v1/analytics/dataset/query` over a real SQLite
36+
driver, on the native-SQL and the ObjectQL strategy alike: a dataset measure
37+
aggregating `'*'` under `sum`, `avg`, `min`, `max` or `count_distinct` answered
38+
`500 DATABASE_ERROR`. A dataset measure compiles to the cube measure it names
39+
verbatim, so the same reading covers an authored cube measure. Such a member
40+
never produced an answer, so no working document changes meaning: the failure
41+
moves from the query to the authoring parse. The two measure slots ask ONE
42+
shared predicate; the rule is cross-field (the slot and its aggregate), so it is
43+
a refinement, which the published JSON Schema cannot carry — both sites are
44+
declared in `dropped-refinements.baseline.json`. The dimension half is a
45+
`pattern`, so `json-schema/**` states it.
46+
47+
## FROM → TO
48+
49+
```
50+
FROM { name: 'deal_metrics', label: 'Deal Metrics', object: 'deal',
51+
dimensions: [{ name: 'stage', field: 'stage' }],
52+
measures: [{ name: 'deals', aggregate: 'sum', field: '*' }] }
53+
-> DatasetSchema.parse accepted it; a dataset query selecting `deals`
54+
answered 500 DATABASE_ERROR
55+
TO -> DatasetSchema.parse throws a ZodError at measures.0.field (custom):
56+
`measures[].field` is the row wildcard `'*'` under `aggregate: 'sum'`. …
57+
defineStack({ datasets }) refuses it at datasets.N.measures.0.field (422
58+
STACK_SCHEMA_INVALID), and POST /api/v1/analytics/dataset/query answers
59+
400 VALIDATION_FAILED for an inline or a saved copy
60+
61+
measures: [{ name: 'deals', aggregate: 'count' }] // a row count
62+
measures: [{ name: 'deal_value', aggregate: 'sum', field: 'amount' }] // an aggregate of a column
63+
64+
FROM defineCube({ name: 'deals', sql: 'deal',
65+
measures: { total: { label: 'Total', type: 'sum', sql: '*' } },
66+
dimensions: { everything: { label: 'All', type: 'string', sql: '*' } } })
67+
TO -> refused at measures.total.sql (custom) and dimensions.everything.sql (invalid_format)
68+
69+
measures: { total: { label: 'Total', type: 'sum', sql: 'amount' } },
70+
dimensions: { stage: { label: 'Stage', type: 'string', sql: 'stage' } }
71+
```
72+
73+
**The one-line fix:** parse each cube and dataset; every refusal at `…sql` /
74+
`…field` naming `'*'` is one member to change — declare a `count` to count rows,
75+
or name the column the measure aggregates (a dimension names the column it
76+
groups by). On a `derived` dataset measure, delete `field`: nothing read it.
77+
There is no mechanical rewrite: `os migrate meta` rewrites nothing for it, and
78+
lists the entry `analytics-row-wildcard-outside-count-refused` as a manual
79+
change that requires your judgment.
80+
81+
**What a stored document meets.** A metadata read still serves it as stored,
82+
with the refusal on its read diagnostics (`_diagnostics`), and a re-save through
83+
the metadata write door is refused at the slot. `POST
84+
/api/v1/analytics/dataset/query` parses every dataset it is handed, inline or
85+
saved, so a stored dataset carrying such a measure answers `400
86+
VALIDATION_FAILED` at `measures.N.field` on every query — including a query that
87+
selects only its other measures, which used to answer — until the member is
88+
fixed: it fails closed. An authored cube reaches the analytics runtime through
89+
the stack definition, whose parse refuses it.
90+
91+
## The kit
92+
93+
- **Schema.** `data/analytics-column-reference.ts` (not published API) declares
94+
the predicate `rowWildcardOutsideCount` and its refusal once; `MetricSchema`
95+
and `DatasetMeasureSchema` call both from a refinement, and
96+
`DimensionSchema.sql` takes `ANALYTICS_COLUMN_PATH`. No export, key or enum
97+
member changes, so the api-surface, authorable-surface and JSON-schema
98+
manifest ratchets are unchanged.
99+
- **ADR-0087.** D3 entry `analytics-row-wildcard-outside-count-refused`. No D2
100+
conversion: rewriting to `count` would change the figure the author asked for,
101+
and only the author can name the column. No `RETIRED_KEYS_BY_MAJOR` row.
102+
- **Dropped refinements.** `data/Metric` and `ui/DatasetMeasure` gain their root
103+
site, and every published schema embedding them gains the embedded site.
104+
- **Liveness.** `analytics_cube` `measures.sql` / `dimensions.sql` and `dataset`
105+
`measures.field` stay `live`, re-verified, their notes re-pointed here.
106+
- **Docs.** The `ui/dataset` reference page is regenerated.
107+
- **Runtime.** Unchanged.
108+
109+
## Reach, measured
110+
111+
- This repository: no example, platform object, doc, skill, script or test
112+
fixture authors `'*'` outside a `count` at the three slots (`git grep` of every
113+
`field` / `sql` value spelled `'*'`, 173 hits, each read in its enclosing
114+
object: 154 under a `count`, the rest QueryAST aggregations, comments and
115+
strategy-level literals). One spec pin admitted `'*'` on a cube dimension; it
116+
now pins the refusal.
117+
- objectui at the pinned `.objectui-sha`: zero `field` / `sql` values spelled
118+
`'*'` (lit controls: 51 `aggregate: 'sum'`, 438 `field: 'amount'`).
119+
- Out-of-repo authored metadata: NOT MEASURED.
120+
121+
<!-- adr-0087: registered analytics-row-wildcard-outside-count-refused -->

‎content/docs/references/ui/dataset.mdx‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,7 @@ const result = DatasetSchema.parse(data);
8383
| **name** | `string` | ✅ | Measure name — e.g. "revenue"; defined once |
8484
| **label** | `string \| Record<string, string>` | optional | Display label — the default-language string, or an inline locale map (`{ en, "zh-CN" }`) resolved at render time |
8585
| **aggregate** | `Enum<'count' \| 'sum' \| 'avg' \| 'min' \| 'max' \| 'count_distinct'>` | optional | Aggregation (sum/avg/count/...); omit when `derived` is set |
86-
| **field** | `string` | optional | Aggregated field: a base field, a relationship path, or "*"; optional for count(*). Never a SQL expression. |
86+
| **field** | `string` | optional | Aggregated field: a base field, a relationship path, or "*" for a count; optional for count(*). Never a SQL expression. |
8787
| **filter** | `any` | optional | |
8888
| **format** | `string` | optional | Numeral pattern for a NUMERIC measure — grouping, decimals, percent; e.g. "0,0.00", "0.0%". An amount takes its symbol from `currency`, not from a "$" in the pattern. A DATE-valued measure never reads a date pattern: `"YYYY-MM-DD"` renders that arm's default face. A date or datetime value reads `format` as a display style — `short` or `relative`, honoured on both. |
8989
| **currency** | `string` | optional | Display currency code (ISO 4217) |
@@ -124,7 +124,7 @@ const result = DatasetSchema.parse(data);
124124
| **name** | `string` | ✅ | Measure name — e.g. "revenue"; defined once |
125125
| **label** | `string \| Record<string, string>` | optional | Display label — the default-language string, or an inline locale map (`{ en, "zh-CN" }`) resolved at render time |
126126
| **aggregate** | `Enum<'count' \| 'sum' \| 'avg' \| 'min' \| 'max' \| 'count_distinct'>` | optional | Aggregation (sum/avg/count/...); omit when `derived` is set |
127-
| **field** | `string` | optional | Aggregated field: a base field, a relationship path, or "*"; optional for count(*). Never a SQL expression. |
127+
| **field** | `string` | optional | Aggregated field: a base field, a relationship path, or "*" for a count; optional for count(*). Never a SQL expression. |
128128
| **filter** | `any` | optional | |
129129
| **format** | `string` | optional | Numeral pattern for a NUMERIC measure — grouping, decimals, percent; e.g. "0,0.00", "0.0%". An amount takes its symbol from `currency`, not from a "$" in the pattern. A DATE-valued measure never reads a date pattern: `"YYYY-MM-DD"` renders that arm's default face. A date or datetime value reads `format` as a display style — `short` or `relative`, honoured on both. |
130130
| **currency** | `string` | optional | Display currency code (ISO 4217) |

0 commit comments

Comments
 (0)