You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Commit d06a84e
Browse filesBrowse the repository at this point in the historyBrowse files
**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 |
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.
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.
`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.
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). -->
Copy file name to clipboardExpand all lines: content/docs/references/ui/component.mdx
+18-2Lines changed: 18 additions & 2 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -1106,7 +1106,7 @@ Sort field and direction pair
1106
1106
|**title**|`string \| Record<string, string>`| optional | Drill-down panel title; defaults to the metric label |
1107
1107
|**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). |
|**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|
1110
1110
|**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`|
1111
1111
|**format**|`string`| optional | Number format pattern (e.g. '0,0', '$0,0', '0%') |
|**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`) |
|**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. |
1131
1139
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 |
0 commit comments