Skip to content

Commit d06a84e

Browse files
committed
Merge origin/main (5b5e83f) into claude/issue-21623-flow-read-node-evaluate-refusal
Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ Co-authored-by: Claude <noreply@anthropic.com>
2 parents b92c808 + 5b5e83f commit d06a84e

38 files changed

Lines changed: 2234 additions & 194 deletions

File tree

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
---
2+
'@objectstack/spec': minor
3+
---
4+
5+
feat(spec)!: an `object-metric` page block's `aggregate` and `trend` take the shape the tile reads instead of any value (#21464)
6+
7+
Clause-②: yes (narrowing)
8+
9+
<!-- adr-0087: registered ui-object-metric-aggregate-trend-typed -->
10+
11+
**BREAKING** — an accept-set narrowing on a published authoring surface, shipped as `minor` under the repo's launch-window convention for accept-set narrowings. What reads the row: the component-props gate on `objectstack validate`, `objectstack build` and `objectstack lint`, which reports a refused value as an advisory `component-props-invalid` / `component-props-unknown-key` finding. A stored page still saves and loads, because a page component's `properties` is not parsed on the metadata save or load path.
12+
13+
**`@objectstack/spec`**
14+
15+
- **Two members are typed.** `ComponentPropsMap['object-metric']` declared `aggregate` and `trend` as `z.unknown()`, although the tile reads each with one shape. Any value passed, and an off-shape one was answered in silence: `aggregate: 'count'` or a function the engine does not have asked the server for a measure it does not have, so the tile showed an error or, on the client-side fallback, a sum it was not asked for; `groupby` for `groupBy` drew one ungrouped number; a `trend` with no `value` painted a lone `%`, and a misspelled member or direction was not drawn.
16+
- **`aggregate` takes its vocabulary by reference** — `{ field?, function, groupBy? }`: `function` is the query engine's own `AggregationFunction` (`count`, `sum`, `avg`, `min`, `max` or `count_distinct` — the six the tile forwards to the data source), with a `field` for every function but `count`; `groupBy` is the chart aggregate's own `ChartGroupBySchema`, a field name or a `{ field, dateGranularity?, alias? }` date-bucket node, and here it is optional, because a metric paints one number over every row. The chart's aggregate is not taken whole: it requires `groupBy`, and its five functions leave out the `count_distinct` the tile draws.
17+
- **`trend` takes the badge's measured shape**, `{ value, label?, direction? }`: `value` a number (painted as a percentage), `label` a string or an inline locale map, `direction` `up`, `down` or `neutral`.
18+
- **`ObjectMetricProps`** carries these types on the two members instead of `unknown`.
19+
- **`drillDown` and `compareTo` are not narrowed** and still accept any value. Each by-reference candidate disagrees with what the tile reads: the chart's drill-down declares a `filter` the tile never reads and refuses the `report` the tile draws as a report body; the dashboard widget's comparison declares a `dimension` that this path never reads. Each is typed once that fork is ruled.
20+
21+
## FROM → TO
22+
23+
| you wrote on an `object-metric` | write instead |
24+
|:--|:--|
25+
| `aggregate: 'count'` | `aggregate: { function: 'count' }` |
26+
| `aggregate: { function: 'sum' }` | name the field: `aggregate: { field: 'amount', function: 'sum' }` |
27+
| `aggregate: { field: 'amount', function: 'median' }` (any function outside the six) | one of `count`, `sum`, `avg`, `min`, `max`, `count_distinct` |
28+
| `aggregate: { field: 'amount', function: 'sum', groupby: 'stage' }` | `groupBy: 'stage'` |
29+
| `aggregate: { function: 'count', dateGranularity: 'month' }` | `aggregate: { function: 'count', groupBy: { field: 'closed_at', dateGranularity: 'month' } }` |
30+
| `trend: 'up'` | `trend: { value: 12, direction: 'up' }` |
31+
| `trend: { value: '12%' }` | `trend: { value: 12 }` (the badge adds the `%`) |
32+
| `trend: { value: 12, direction: 'rising' }` | `direction: 'up'` |
33+
34+
The one-line fix: write each member as the table above shows. No conversion is registered, because an off-shape value has no rewrite that both keeps what the tile shows today and honours what the author wrote; the D3 entry `ui-object-metric-aggregate-trend-typed` carries that judgment.
35+
36+
## Who is affected, measured
37+
38+
A writer is a value written on an `object-metric`: a page-component node (an object literal naming the type, a literal annotated with the block's type, a direct parse through the row), the block's React component with the member as a prop or inside `schema={{…}}`, or the argument of a local test helper that mounts one. Values resolve through same-file constants; helper arguments and `it.each` rows were resolved by hand. Each value was parsed through the built row.
39+
40+
- **objectstack** at `b610eabf72`, over `examples/`, `packages/` (with `packages/apps/`), `content/`, `skills/` and `apps/`: 22 `aggregate` values, all `{ field, function }` with `count` or `sum` — the showcase's thirteen KPI tiles (`index.ts`, `my-work.page.ts` and the `kpi()` helper in `command-center.page.ts`), two in the layout-DSL protocol page and six copies in the spec and lint tests. 21 parse. The 22nd is the `kind: 'html'` example in `skills/objectstack-ui/rules/pages.md`, `aggregate="count"`, which this row does not judge (the html tier is compiled against the component manifest, not parsed through `ComponentPropsMap`). No `trend` is authored.
41+
- **objectui** at the `.objectui-sha` pin `89cad75d55`: 66 `aggregate` values (64 static) and 12 `trend` values (all static). 63 and 11 parse. The two refused values are test fixtures probing that the tile does not draw them: an array `groupBy` the data adapter refuses at the producer (`objectMetricStructuredGroupBy-8613`), and a `trend` carrying `percent` and `caption`, which the test asserts the badge never draws (`objectMetricTrendMembers-8071`). The two values that are not static are the dashboard relays' run-time hand-offs (`DashboardRenderer`, `DashboardGridLayout`). objectui's unit-rule pin mounts a `count_distinct` tile, which parses.
42+
- **Deployed metadata** was not measured.
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
'@objectstack/driver-sql': patch
3+
'@objectstack/service-analytics': patch
4+
---
5+
6+
On SQLite, a `week` date bucket is grouped in SQL, and the analytics SQL echo never prints a bucket statement that SQLite refuses (#21595).
7+
8+
Clause-②: no
9+
10+
- **What was wrong.** `driver-sql` grouped `day`, `month`, `quarter` and `year` in SQL on SQLite, but not `week`. Its `supports.queryDateGranularity` said `week: false`, so the engine bucketed weeks in memory, and the ObjectQL face of `POST /api/v1/analytics/query` and `POST /api/v1/analytics/sql` echoed the bucket as `date_trunc('week', col)`. SQLite has no `date_trunc`, so that echo could not run. A non-UTC `timezone` on SQLite gave the same echo for every granularity.
11+
- **What it does now.**
12+
- SQLite advertises all five granularities. `week` buckets as `YYYY-Www`, the ISO 8601 week that the PostgreSQL and MySQL arms answer. The expression does not use `strftime('%V')`, which needs SQLite 3.46: `@libsql/client` 0.18.0 bundles SQLite 3.45.1, where `%V` answers NULL. It runs on better-sqlite3, on libSQL (`driver-turso`) and on sql.js (`driver-sqlite-wasm`). A `Field.date` still buckets as its own calendar day.
13+
- The echo prints that expression for a `week` bucket on SQLite, and the statement runs.
14+
- With a non-UTC `timezone` on SQLite, `POST /api/v1/analytics/sql` refuses with `NOT_IMPLEMENTED` / 501, declared as a refusal so its message reaches the caller. `POST /api/v1/analytics/query` still answers the rows, and its answer carries no `sql`. The engine buckets on that zone's calendar in memory, and SQLite has no time-zone database, so no SQLite statement produces those keys.
15+
- **Where it shows.** `aggregate()` with a `week` group on SQLite, `SqlDriver.dateBucketSql()`, and the analytics SQL echo. A query sent with `timezone: 'UTC'`, or with no `timezone`, still echoes the driver's own expression.
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
'@objectstack/objectql': patch
3+
---
4+
5+
`LifecycleService` asks the registry whether `sys_organization` is registered before its governance tenant scan reads it, so a composition that registers no `sys_organization` sweeps single-tenant again instead of aborting every sweep
6+
7+
Clause-②: no
8+
9+
- The engine's in-process verbs refuse an object name the registry does not resolve (`OBJECT_NOT_FOUND`, 404) before any driver is asked. Take a composition with a settings service and lifecycle-declared objects but no `sys_organization` object. Its tenant scan got that refusal instead of a missing table, so every sweep aborted before applying any policy. The scan now asks `engine.registry.getObject('sys_organization')` first, the same shape `ObjectQL.probeInstallOrganizations` takes. An unregistered object answers "no tenant overrides", and the sweep runs one global pass on each declared window.
10+
- A registered `sys_organization` is read as before. A missing table is still the one benign driver cause. Every other failure still aborts the sweep and is reported through `report.errors`, an `OBJECT_NOT_FOUND` from that read included.
11+
- `LifecycleEngineLike['registry']` now declares the optional `getObject?(name)` member the scan reads. A registry without it cannot be asked, and the scan then reads exactly as before. No new export and no change to the sweep report's shape.
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
---
2+
'@objectstack/objectql': minor
3+
---
4+
5+
fix(objectql)!: the record a write returns, and the prior read it binds as `previous`, serve the object's declared fields and the platform's system columns, never a column no metadata declares (#21613)
6+
7+
**BREAKING (narrowing)** — what a released write door returns shrinks. A column that
8+
no metadata declares, typically a field retired in an upgrade whose column additive
9+
schema sync leaves in the table until `os migrate apply --allow-destructive`, is no
10+
longer returned by any write through the engine. This completes the read-side rule of
11+
the previous release (#21571) for writes.
12+
13+
| write | before | now |
14+
| --- | --- | --- |
15+
| `PATCH /api/v1/data/:object/:id`, batch `update`, `updateMany` | the post-write row read back with `select *`: retired columns with their stored values | the declared fields, the registry's system columns, `id`, `created_at`, `updated_at` |
16+
| `POST /api/v1/data/:object`, `POST /api/v1/data/:object/:id/clone`, `createMany`, batch `create` / `upsert`, `insertMany` outcomes | the inserted row from `returning('*')`: retired columns as `null` | the same declared set |
17+
| `data.record.created` / `data.record.updated` events, and the webhook deliveries that carry them as `after` | the same whole row | the declared set |
18+
| `engine.insert` / `engine.update` in process (actions, flows, plugins), and a hook's `ctx.result` | the whole row | the declared set |
19+
| a hook's `ctx.previous` on update and delete (by id and per row), and the audit ledger's delete `old_value` and create `new_value` | the whole stored row, retired columns and their values included | the declared set |
20+
21+
**Unchanged:** declared fields keep their treatment. An `internal: true` field is still
22+
returned whole on the engine-level write result to the privileged writer that just
23+
wrote it, and still stripped from every data-door response; formulas are still
24+
hydrated onto the result; the registry-injected tenant, owner and audit columns are
25+
still returned. No driver changed: the engine shapes the rows any driver returns, so
26+
the answer is the same on every driver and every door. A retired column's values stay
27+
in the table.
28+
29+
**If you still read a retired column's values off a write** (for example a hook, a
30+
flow or a webhook receiver that copied an old column into its replacement): run that
31+
conversion BEFORE upgrading to this release, while the old field is still declared, or,
32+
once it lands, read the unmapped columns through the operator-only `os migrate` read
33+
(objectstack#21573). There is no flag that re-opens undeclared columns on a write. A
34+
reader that needs a column must declare it as a field; a validation rule, a
35+
`readonlyWhen` or a hook condition that reads a column must name a declared field too.
36+
37+
Clause-②: no (narrowing)
38+
39+
<!-- adr-0087: not-required (no-migration-prescription) No authorable key, spelling, export, config field or stored shape is removed or renamed, and no stored row is read or rewritten: the change narrows which physical columns the engine's write verbs and their prior reads return, to the field map the metadata already declares. A retired column's values stay in the table; what an app does with them is an operator action stated above (convert before upgrading, or the operator-only os migrate read), not a FROM to TO mapping that objectstack migrate meta could apply. The other categories are closed on facts: the package publishes (not unpublished); no ADR-0087 id covers a write result's projection (not registered / already-registered); and the change is runtime behaviour, not a declaration (not runtime-interface-only / type-surface-only). -->

‎content/docs/data-modeling/queries.mdx‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -677,9 +677,9 @@ A `groupBy` entry is either a bare field name or a structured node that buckets
677677

678678
`dateGranularity` accepts `day` | `week` | `month` | `quarter` | `year`. The engine
679679
pushes bucketing down to the driver only when that driver advertises the granularity
680-
via `supports.queryDateGranularity` — SQLite reports `week: false`, for instance, so a
681-
weekly bucket falls back to fetching rows and bucketing in memory. The fallback is
682-
transparent to the query — you get the same buckets either way.
680+
via `supports.queryDateGranularity` — the Turso remote transport reports none, for
681+
instance, so every bucket there falls back to fetching rows and bucketing in memory. The
682+
fallback is transparent to the query — you get the same buckets either way.
683683

684684
<Callout type="warn">
685685
Buckets are computed in **UTC**. A non-UTC reference timezone is only reachable through

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

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1106,7 +1106,7 @@ Sort field and direction pair
11061106
| **title** | `string \| Record<string, string>` | optional | Drill-down panel title; defaults to the metric label |
11071107
| **icon** | `string` | optional | Lucide icon name drawn in the metric tile header, inside the `colorVariant`-tinted square. Read on this component — `ObjectMetricWidget` forwards it to `MetricWidget`, which resolves it with `getLazyIcon` (the `LazyIcon` module: kebab-case or PascalCase, degrading to the `Database` glyph on an unknown name). |
11081108
| **colorVariant** | `Enum<'default' \| 'blue' \| 'teal' \| 'orange' \| 'purple' \| 'success' \| 'warning' \| 'danger'>` | optional | Icon container color variant |
1109-
| **aggregate** | `any` | optional | Aggregation config (`{ field, function, groupBy? }`) run against the object |
1109+
| **aggregate** | `{ field?: string; function: Enum<'count' \| 'sum' \| 'avg' \| 'min' \| 'max' \| 'count_distinct'>; groupBy?: string \| object }` | optional | Aggregation run against the object: `{ field?, function, groupBy? }` — `function` one of count / sum / avg / min / max / count_distinct (`field` needed for all but count), `groupBy` a field name or a `{ field, dateGranularity }` node, absent for one number over every row |
11101110
| **filter** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Filter the aggregation is scoped by — the ViewFilterRule array form `[{ field, operator, value }, ...]`, the one filter orthography every `filter` door in this map shares. The MongoDB-style record form is refused — see migration `element-data-source-and-object-block-filter-rule-array` |
11111111
| **format** | `string` | optional | Number format pattern (e.g. '0,0', '$0,0', '0%') |
11121112
| **currency** | `string` | optional | ISO currency code (e.g. 'USD') — enables currency formatting |
@@ -1115,10 +1115,18 @@ Sort field and direction pair
11151115
| **invert** | `boolean` | optional | Display `1 - value` for opposite-signal gauges (compliance/uptime) |
11161116
| **variant** | `Enum<'card' \| 'bare'>` | optional | Layout variant |
11171117
| **fallbackValue** | `string \| number` | optional | Static value shown when no data source is available |
1118-
| **trend** | `any` | optional | Static trend info (`{ value, label, direction }`) |
1118+
| **trend** | `{ value: number; label?: string \| Record<string, string>; direction?: Enum<'up' \| 'down' \| 'neutral'> }` | optional | Static trend badge `{ value, label?, direction? }` — `value` painted as a percentage, `direction` up / down / neutral. A `compareTo`-derived trend replaces it |
11191119
| **drillDown** | `any` | optional | Click-through drill config — opens the underlying records |
11201120
| **compareTo** | `any` | optional | Period-over-period comparison (`{ kind: 'previousPeriod' \| 'previousYear' }`) |
11211121

1122+
### Nested Shape: `ObjectMetricProps.aggregate`
1123+
1124+
| Property | Type | Required | Description |
1125+
| :--- | :--- | :--- | :--- |
1126+
| **field** | `string` | optional | Field to aggregate — required for every function but `count`, which counts rows |
1127+
| **function** | `Enum<'count' \| 'sum' \| 'avg' \| 'min' \| 'max' \| 'count_distinct'>` | ✅ | Aggregation function — `count`, `sum`, `avg`, `min`, `max` or `count_distinct` |
1128+
| **groupBy** | `string \| { field: string; dateGranularity?: Enum<'day' \| 'week' \| 'month' \| 'quarter' \| 'year'>; alias?: string }` | optional | Field the rows are grouped by, or a `{ field, dateGranularity }` date-bucket node. Omit it for the one number over every row (`_all`) |
1129+
11221130
### Nested Shape: `ObjectMetricProps.filter[number]`
11231131

11241132
View filter rule
@@ -1129,6 +1137,14 @@ View filter rule
11291137
| **operator** | `Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>` | ✅ | Filter operator |
11301138
| **value** | `string \| number \| boolean \| null \| (string \| number)[]` | optional | Filter value. The accepted SHAPE depends on the operator: `in` / `not_in` take an array (any length, including []), `between` takes exactly [min, max], every other operator takes a scalar. The unary operators (is_empty / is_not_empty / is_null / is_not_null) take their direction from the operator name and ignore this key. One operator bounds the VALUE as well as the shape: `icontains` takes a NON-EMPTY STRING, the comparand the Filter Protocol conformance table declares for it — an empty comparand constrains nothing and a non-string one would answer a query nobody wrote, and both are refused at the query path too. |
11311139

1140+
### Nested Shape: `ObjectMetricProps.trend`
1141+
1142+
| Property | Type | Required | Description |
1143+
| :--- | :--- | :--- | :--- |
1144+
| **value** | `number` | ✅ | The change shown on the badge, painted as a percentage (`12` reads `12%`) |
1145+
| **label** | `string \| Record<string, string>` | optional | Badge caption — a string or an inline locale map. The tile-level `description` outranks it in the one caption slot they share |
1146+
| **direction** | `Enum<'up' \| 'down' \| 'neutral'>` | optional | Arrow beside the value; omit it for no arrow |
1147+
11321148

11331149
---
11341150

‎docs/audits/2026-07-unknown-key-strictness-ledger.counts/ui.md‎

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ The `strict` column is the one the campaign schedules against; it counts both th
2121

2222
| Dir | Sites | strict | passthrough | catchall | strip |
2323
|---|---|---|---|---|---|
24-
| `ui/` | 192 | 181 | 4 | 0 | 7 |
24+
| `ui/` | 194 | 183 | 4 | 0 | 7 |
2525

2626
## `ui/` — sites
2727

@@ -36,7 +36,7 @@ classify and is not listed (it becomes reportable the day it grows its first sit
3636
| `app.zod.ts` | 19 |
3737
| `bulk-action.zod.ts` | 4 |
3838
| `chart.zod.ts` | 8 |
39-
| `component.zod.ts` | 62 |
39+
| `component.zod.ts` | 64 |
4040
| `dashboard.zod.ts` | 11 |
4141
| `dataset.zod.ts` | 4 |
4242
| `i18n.zod.ts` | 1 |
@@ -46,23 +46,23 @@ classify and is not listed (it becomes reportable the day it grows its first sit
4646
| `sharing.zod.ts` | 1 |
4747
| `view.zod.ts` | 60 |
4848
| `widget.zod.ts` | 1 |
49-
| **total** | **192** |
49+
| **total** | **194** |
5050

5151
## `ui/` — open
5252

5353
Per file, how many of its sites still silently discard unknown keys. The `Class`
5454
column that decides the bucket split is hand-written in the ledger; the arithmetic
5555
over it is here.
5656

57-
**7 strip of 192**, in 4 file(s).
57+
**7 strip of 194**, in 4 file(s).
5858

5959
| File | Strip | Sites |
6060
|---|---|---|
6161
| `action-params.zod.ts` | 1 | 1 |
6262
| `app.zod.ts` | 1 | 19 |
6363
| `view.zod.ts` | 4 | 60 |
6464
| `widget.zod.ts` | 1 | 1 |
65-
| **total** | **7** | **192** |
65+
| **total** | **7** | **194** |
6666

6767
| Bucket | Sites |
6868
|---|---|

0 commit comments

Comments
 (0)