Skip to content

Commit bb4d499

Browse files
author
Claude
committed
Merge remote-tracking branch 'origin/main' into claude/issue-5134-sql-driver-boolean-identity
2 parents 49ca5e1 + 4addd9d commit bb4d499

38 files changed

Lines changed: 3263 additions & 239 deletions
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
---
2+
"@objectstack/spec": minor
3+
"@objectstack/lint": minor
4+
---
5+
6+
feat(spec,lint): declare the chart segment drill — `ChartDrillDownSchema`, on the react tier where it is actually read (#5022)
7+
8+
`drillDown` has driven a real capability since long before this release: click a
9+
bar or a slice on an `<ObjectChart>` and objectui opens the underlying records,
10+
filtered by the clicked category, in a drawer. The protocol declared it
11+
**nowhere**. objectui read it as `(schema as any).drillDown`, so every key inside
12+
it — right, wrong, or misspelled — reached the renderer unchecked, and a typo was
13+
simply ignored at click time. This is Prime Directive #10 inverted: not declared
14+
without being delivered, but delivered without ever being declared.
15+
16+
It is declared now, as `ChartDrillDownSchema`, and it is **additive** — nothing
17+
that parsed before stops parsing.
18+
19+
## What you can write
20+
21+
`drillDown` is a prop on the react-tier `<ObjectChart>` block:
22+
23+
```jsx
24+
<ObjectChart objectName="opportunity"
25+
aggregate={{ function: 'sum', field: 'amount', groupBy: 'stage' }}
26+
drillDown={{ columns: ['name', 'amount'], maxRows: 50 }} />
27+
```
28+
29+
| key | type | meaning |
30+
|---|---|---|
31+
| `enabled` | `boolean` | Only needed to force the drill OFF — the block being present already means on, so `drillDown={{}}` enables it |
32+
| `filter` | `Record<string, unknown>` | Filter for the drilled list; values support `${event.*}`. Omit it and the filter is derived from `aggregate.groupBy` equal to the clicked category |
33+
| `title` | `string` | Drawer/dialog heading; supports `${event.*}` |
34+
| `target` | `'drawer' \| 'dialog'` | In-place side sheet (default), or a centered modal when the chart is already inside a drawer |
35+
| `columns` | `string[]` | Column whitelist for the drilled list |
36+
| `maxRows` | `number` | Rows per page in the drilled list |
37+
38+
Every one of those six is a key objectui's `ObjectChart` was measured to read.
39+
The renderer's own drill type is wider — it is shared with the table / pivot /
40+
metric widgets — and the extra keys are **deliberately not declared**, because a
41+
chart reads none of them:
42+
43+
- **`mode`** (`'filter'`/`'record'`) is a table/pivot/metric key. A chart segment
44+
is always an aggregate, so there is nothing to discriminate.
45+
- **`report`** (drill into a report instead of a record list) is a metric/pivot
46+
capability.
47+
- **`view`** and **`sort`** are read by *no* renderer at all (objectui#3354).
48+
- **`target: 'navigate'`** is implemented for the other widgets but not for a
49+
chart, which falls back to the drawer.
50+
51+
Writing any of them is now a loud rejection that says which surface owns it,
52+
rather than a value that silently does nothing.
53+
54+
## Where it is NOT declared, and why that is deliberate
55+
56+
**Not on `ChartConfigSchema`, and not a dashboard widget key.** A dashboard
57+
widget has no per-widget drill configuration, by design: an ADR-0021
58+
dataset-bound widget drills through the semantic layer, deriving the target
59+
object and filter from the dataset row that was clicked. That is what
60+
`content/docs/ui/dashboards.mdx` has said all along, and it is what the renderer
61+
does — `DashboardRenderer` never reads `chartConfig`, and `DatasetWidget`
62+
forwards exactly one key out of it (`showLegend`). Declaring the drill there
63+
would have produced authorable metadata that parses clean and never reaches a
64+
renderer — the failure this campaign removes elsewhere.
65+
66+
So the three places an author might reach for it now answer instead of shrugging:
67+
68+
- `widget.chartConfig.drillDown` → rejected, pointing at the react-tier prop.
69+
- `widget.drillDown` / `widget.drilldown` → rejected, explaining that dashboard
70+
drill-through is **automatic**, and naming both configurable drills.
71+
- `report.drillDown` → rejected, pointing back at the chart prop.
72+
73+
## `drillDown` is not `drilldown`
74+
75+
Two capabilities, one letter apart, and they are now disambiguated in both
76+
directions at the schema gate:
77+
78+
| | `drillDown` | `drilldown` |
79+
|---|---|---|
80+
| spelling | camelCase | all lowercase |
81+
| type | configuration object | boolean |
82+
| surface | react `<ObjectChart>` prop | `ReportSchema` key (ADR-0021 D2, on by default) |
83+
84+
Edit distance alone gets this wrong — the two spellings are a distance of 1, so a
85+
plain "did you mean" would happily send an author writing `drillDown` on a report
86+
to `drilldown`, where their config object then fails a second time as a boolean.
87+
Both gates name the **type** difference, not just the spelling.
88+
89+
## Enforced, not just declared
90+
91+
`@objectstack/lint`'s react-page publish gate now **parses** the schema
92+
(`react-chart-drilldown-invalid`) against a static `drillDown={{…}}` literal,
93+
rather than re-deriving the rules. Unknown keys, the wrong `target`, and the
94+
near-key spelling all fail the build with the schema's own prescription. A value
95+
assembled from React state is skipped, unchanged: an unresolvable binding is not
96+
a wrong one (ADR-0072 D1).
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
---
2+
"@objectstack/metadata": patch
3+
---
4+
5+
fix(metadata): 文件系统改动同样失效本节点的 `listCache`/`registry`,不再只叫醒 watcher (#5218)
6+
7+
`NodeMetadataManager.handleFileEvent()` 在 chokidar 报告 `add` / `change` /
8+
`unlink` 之后只做两件事:重新 `load()` 一次文件内容,然后 `notifyWatchers()`
9+
它既不碰 `listCache` 也不碰 `registry` —— 而 `load()` 是纯读路径(它委托给
10+
`loadDiagnosed`,后者只遍历 loader),两个缓存都不写。
11+
12+
后果是**同一个 manager 的两个读接口互相矛盾**。手改 `rootDir` 下的
13+
`view/<name>.json` 之后:
14+
15+
- `get(type, name)` 是新的 —— 它穿透到 `FilesystemLoader`;
16+
- `list(type)``LIST_CACHE_TTL_MS`(30 秒)窗口内继续返回改动前的清单 ——
17+
REST `/api/v1/metadata/:type`、Studio 左栏、`listViews()` 等一切走 `list()`
18+
的读都受影响。
19+
20+
更糟的是被这次事件叫醒的消费者(Studio HMR/SSE 流、ObjectQL SchemaRegistry
21+
桥)正是通过回头拉 `list()` 来响应的,于是这次唤醒**递回了它自己刚刚宣告已失效
22+
的那份数据**
23+
24+
这与 #5109(集群对端写入不失效本节点缓存)是同一形状、不同触发源,因此复用该
25+
修复落地的 `invalidateForForeignWrite(type, name)`(可见性由 `private` 放宽为
26+
`protected`):文件改动正是「不是经由本 manager 写接口发生的写入」,没有任何东西
27+
替它刷新过缓存,delete-而非-预填 的语义也正好对上 —— 穿透回 loader 读到的就是
28+
文件的真相。
29+
30+
两点与基类其余写路径一致的约束:
31+
32+
- **先失效,再通知**(`register` / `unregister` / `applyRepoEvent` / 集群订阅者
33+
都是这个次序),使 watcher 不可能同时观察到事件与事件前的缓存;
34+
- **registry 条目一并删除**,不只是列表缓存。FS 加载的条目本来就不进 registry,
35+
通常无可删;但当同名条目此前被 `register()` / `registerInMemory()` 写过时,
36+
它在 `get()``list()` 中都会**遮蔽** loader,只删列表缓存会让那份陈旧副本
37+
一直应答下去。
38+
39+
命中面主要是开发期:`MetadataPlugin` 默认 `watch: true`,在
40+
`bootstrap: 'artifact-only'` 下被强制关闭,`standalone-stack` 显式传
41+
`watch: false`。因此 artifact 模式的 `os dev` 与 standalone 不受影响,非 artifact
42+
的默认 `MetadataPlugin` 装配受影响。
43+
44+
`type === 'api'` 的行为不变:端点索引此前已由 #5089 装的 `subscribe('api', …)`
45+
那条缝覆盖,本次改动把 `invalidateListCache` 那条缝也接上,两条缝对称。
46+
`EndpointMatcher.invalidate()` 是两次赋 `undefined`,重复失效幂等。
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
---
2+
"@objectstack/driver-sql": minor
3+
"@objectstack/plugin-auth": patch
4+
---
5+
6+
feat(driver-sql)!: organization-scoped uniques are NULL-safe — `COALESCE(organization_id, '__global__')` key part + `unique: 'organization'` on declared indexes (ADR-0120 D3/D4, #5030)
7+
8+
SQL UNIQUE is NULL-distinct, so the `(organization_id, field)` composite #3696
9+
introduced enforced **nothing** on rows whose organization is NULL — which on a
10+
single-tenant stack (where the kernel injects the column and never fills it) is
11+
**every row**: field-level `unique: true` was a silent no-op there, measured in
12+
#5030. Per ADR-0120 D3, every organization-scoped unique now materializes its
13+
organization key part as `COALESCE(organization_id, '__global__')`: NULL-organization
14+
rows collapse into one platform bucket, unique among themselves; non-NULL rows
15+
are untouched. Storage stays NULL — the sentinel exists only inside the index
16+
key, and it is the same word the autonumber sequence table already uses
17+
(`GLOBAL_TENANT`), so a constraint-violation error reads as "the platform
18+
bucket collided", not as corrupt data.
19+
20+
What changes, concretely:
21+
22+
- **Field-level `unique: true`** (and the new explicit synonym
23+
`'organization'`) on a tenant-scoped object → composite
24+
`(COALESCE(tenantField, '__global__'), field)`. `unique: 'global'` and
25+
tenant-less objects are unchanged.
26+
- **Declared indexes gain the ADR-0120 D1 scope vocabulary at the driver**:
27+
`unique: 'organization'` prepends the NULL-safe organization key part to the
28+
listed columns (degrading to the listed columns on a tenant-less object; a
29+
listed tenant column is made NULL-safe in place instead — the S6 respelling).
30+
`unique: true` / `'global'` on a declared index stays **verbatim** — the
31+
#3696 contract, now the `'global'` arm; the nine engine dedup/idempotency
32+
keys keep their exact physical shape. (The spec/lint side of the vocabulary
33+
lands separately via #4986; the driver deliberately merges first.)
34+
- **Drift detection reads both sides through one normalization**
35+
(the #4884 discipline, extended to the tenant key part): the physical
36+
`COALESCE(organization_id, <literal>)` form is attributed to the column,
37+
compared **literal-agnostically**, and recognised as the sync's own
38+
vocabulary — a healthy database reports zero drift on every dialect.
39+
- **Existing bare composites migrate through the ceremony (ADR-0120 D4)**:
40+
`(organization_id, X) → (COALESCE(organization_id, '__global__'), X)`
41+
surfaces as a `recreate_index` drift op — a pure tightening — gated by a
42+
**duplicate pre-flight probe**. Clean probe → the op grades `safe` and dev
43+
`autoMigrate: 'safe'` / a plain `os migrate apply` applies it. Duplicates
44+
(data the void constraint wrongly admitted) → the op is **blocked** with a
45+
per-group row report, the old index stays in place, and apply re-probes so
46+
even `--allow-destructive` cannot drop a constraint whose replacement is not
47+
creatable. Deduplicate, re-plan, apply.
48+
- **`'__global__'` is reserved at the organization-minting seam**
49+
(plugin-auth): an organization whose id or slug equals the sentinel is
50+
rejected at creation with a prescriptive error (ADR-0120 D3 guardrail).
51+
52+
Migration note for operators: on databases with pre-existing
53+
organization-composite uniques, the first `os migrate plan` after upgrading
54+
shows one `recreate_index` per affected index. On healthy data it auto-applies
55+
in dev and is a no-op content-wise; a blocked op means the #5030 defect
56+
admitted real duplicate rows — resolve the listed rows first. MySQL < 8.0.13 /
57+
MariaDB cannot express the functional key part: the driver degrades to the
58+
bare composite, says exactly what is not enforced at `error` level, and keeps
59+
reporting the tightening as drift for after the server upgrade.
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
---
2+
"@objectstack/service-storage": major
3+
---
4+
5+
fix(service-storage)!: a `sys_file` / `sys_upload_session` write that never landed no longer reports success (#5216)
6+
7+
`StorageMetadataStore` wrapped **all eight** of its `IDataEngine` calls in
8+
`try { … } catch { /* ignore */ }` — no logger, no rethrow, no degradation flag.
9+
Because `if (this.engine)` had already separated "no data engine wired" out,
10+
those catches could only ever fire on a **runtime** failure of an engine that is
11+
wired: a constraint violation, a connection blip, an RLS refusal, a table that
12+
was never migrated. Every one of them was swallowed, and the store returned the
13+
record it had just put in a process-local `Map`.
14+
15+
The result on `sys_file` — mostly-permanent business truth with compliance value
16+
(#5202) — was the shape AGENTS.md → "Degradation log levels" exists to forbid:
17+
the bytes landed in the storage backend, the metadata row **never existed**, and
18+
`POST /api/v1/storage/upload/presigned` answered `200 { success: true }` with a
19+
`fileId` naming nothing. A read in the same process then found the Map shadow,
20+
so even a self-check looked healthy — until the worker recycled and the
21+
attachment became permanently unaddressable, with not one line of log pointing
22+
at the cause. On `sys_upload_session` the same swallow made multi-worker chunked
23+
uploads die as unexplained stalls instead of a diagnosable error.
24+
25+
**What changes.** With a data engine wired, the engine is now the only store:
26+
27+
- **Writes** (`createFile`, `updateFile`, `deleteFile`, `createSession`,
28+
`updateSession`, `deleteSession`) propagate the failure as a new
29+
`StorageMetadataStoreError` instead of returning a value. Nothing is mirrored
30+
into the `Map`, so there is no in-process shadow left behind to make a lost
31+
write look like a landed one.
32+
- **Reads** (`getFile`, `getSession`) distinguish a **miss** from an **outage**.
33+
`findOne` returning nothing is still a miss and still returns `null` (the REST
34+
layer answers 404, unchanged). An engine that *throws* now propagates:
35+
substituting this process's `Map` for an unreachable engine would dress a
36+
stale or empty local guess up as the persisted answer, which under multiple
37+
workers is a different wrong answer per worker.
38+
- The process-local `Map` is now exactly what the class doc always claimed —
39+
the stand-in for deployments with **no** engine wired (tests, dev). Behaviour
40+
of `new StorageMetadataStore(null)` is unchanged in every respect.
41+
42+
**Breaking, and where it shows.** No API signature changed; what changed is that
43+
these calls can now reject. Requests that previously received `200` over a lost
44+
write receive `500 INTERNAL` from the existing storage route handlers (they
45+
already wrapped every handler in `catch → sendError(500, 'INTERNAL', …)`, so no
46+
route needed editing), and a read attempted during an engine outage answers
47+
`500` rather than a false `404 FILE_NOT_FOUND`. If you call
48+
`StorageMetadataStore` directly, the six write methods and the two read methods
49+
may now throw `StorageMetadataStoreError``error.objectName`
50+
(`sys_file` / `sys_upload_session`), `error.operation`
51+
(`insert` / `update` / `delete` / `findOne`) and `error.cause` (the engine's own
52+
failure) identify it, and `error.message` states the consequence and the fix.
53+
54+
There is nothing to migrate: no deployment can have been *relying* on the old
55+
behaviour, because the old behaviour produced no signal to rely on. What a
56+
deployment may newly *see* is a 500 that was previously an undetected data loss.
57+
`StorageMetadataStoreError` and the `StorageMetadataOperation` type are exported
58+
from `@objectstack/service-storage` for callers that want to tell a metadata
59+
outage apart from any other 500.

content/docs/references/ui/chart.mdx

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,8 @@ Provides a comprehensive set of chart types for data visualization.
1818
## TypeScript Usage
1919

2020
```typescript
21-
import { ChartAggregateSchema, ChartAggregateFunctionSchema, ChartAnnotationSchema, ChartAxisSchema, ChartConfigSchema, ChartGroupBySchema, ChartInteractionSchema, ChartSeriesSchema, ChartTypeSchema } from '@objectstack/spec/ui';
22-
import type { ChartAggregate, ChartAggregateFunction, ChartAnnotation, ChartAxis, ChartConfig, ChartGroupBy, ChartInteraction, ChartSeries, ChartType } from '@objectstack/spec/ui';
21+
import { ChartAggregateSchema, ChartAggregateFunctionSchema, ChartAnnotationSchema, ChartAxisSchema, ChartConfigSchema, ChartDrillDownSchema, ChartGroupBySchema, ChartInteractionSchema, ChartSeriesSchema, ChartTypeSchema } from '@objectstack/spec/ui';
22+
import type { ChartAggregate, ChartAggregateFunction, ChartAnnotation, ChartAxis, ChartConfig, ChartDrillDown, ChartGroupBy, ChartInteraction, ChartSeries, ChartType } from '@objectstack/spec/ui';
2323

2424
// Validate data
2525
const result = ChartAggregateSchema.parse(data);
@@ -113,6 +113,22 @@ Inline aggregation for an object-bound chart
113113
| **aria** | `{ ariaLabel?: string; ariaDescribedBy?: string; role?: string }` | optional | ARIA accessibility attributes |
114114

115115

116+
---
117+
118+
## ChartDrillDown
119+
120+
### Properties
121+
122+
| Property | Type | Required | Description |
123+
| :--- | :--- | :--- | :--- |
124+
| **enabled** | `boolean` | optional | Turn the segment drill on/off; the block being present already means on, so this is only needed to force it off |
125+
| **filter** | `Record<string, any>` | optional | Filter for the drilled list; values support $`{event.*}`. Omit to derive it from the clicked category |
126+
| **title** | `string` | optional | Drill drawer/dialog heading; supports $`{event.*}` interpolation |
127+
| **target** | `Enum<'drawer' \| 'dialog'>` | optional | Where the drilled list opens: 'drawer' (default, side sheet) or 'dialog' (centered modal) |
128+
| **columns** | `string[]` | optional | Field names to show as columns in the drilled list (default: the table's own columns) |
129+
| **maxRows** | `integer` | optional | Rows per page in the drilled list |
130+
131+
116132
---
117133

118134
## ChartGroupBy

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

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ regenerate.
2121
| Measure | Value |
2222
|---|---|
2323
| Triaged directories | 5 |
24-
| Object sites in them | 482 |
24+
| Object sites in them | 483 |
2525
| Still-open (strip) sites | 235 |
2626
| Files carrying at least one | 36 |
2727

@@ -43,12 +43,12 @@ The `strict` column is the one the campaign schedules against; it counts both th
4343

4444
| Dir | Sites | strict | passthrough | catchall | strip |
4545
|---|---|---|---|---|---|
46-
| `ui/` | 198 | 117 | 5 | 0 | 76 |
46+
| `ui/` | 199 | 118 | 5 | 0 | 76 |
4747
| `data/` | 162 | 41 | 1 | 0 | 120 |
4848
| `automation/` | 75 | 49 | 0 | 0 | 26 |
4949
| `security/` | 20 | 7 | 0 | 0 | 13 |
5050
| `studio/` | 27 | 27 | 0 | 0 | 0 |
51-
| **total** | **482** | **241** | **6** | **0** | **235** |
51+
| **total** | **483** | **242** | **6** | **0** | **235** |
5252

5353
## File-level triage — site counts
5454

@@ -64,7 +64,7 @@ classify and is not listed (it becomes reportable the day it grows its first sit
6464
| `animation.zod.ts` | 4 |
6565
| `app.zod.ts` | 18 |
6666
| `bulk-action.zod.ts` | 3 |
67-
| `chart.zod.ts` | 7 |
67+
| `chart.zod.ts` | 8 |
6868
| `component.zod.ts` | 29 |
6969
| `dashboard.zod.ts` | 11 |
7070
| `dataset.zod.ts` | 4 |
@@ -81,7 +81,7 @@ classify and is not listed (it becomes reportable the day it grows its first sit
8181
| `touch.zod.ts` | 7 |
8282
| `view.zod.ts` | 50 |
8383
| `widget.zod.ts` | 9 |
84-
| **total** | **198** |
84+
| **total** | **199** |
8585

8686
### `data/` — sites
8787

@@ -161,13 +161,13 @@ over it is here.
161161

162162
### `ui/` — open
163163

164-
**76 strip of 198**, in 13 file(s).
164+
**76 strip of 199**, in 13 file(s).
165165

166166
| File | Strip | Sites |
167167
|---|---|---|
168168
| `animation.zod.ts` | 4 | 4 |
169169
| `app.zod.ts` | 1 | 18 |
170-
| `chart.zod.ts` | 2 | 7 |
170+
| `chart.zod.ts` | 2 | 8 |
171171
| `component.zod.ts` | 29 | 29 |
172172
| `dnd.zod.ts` | 4 | 4 |
173173
| `i18n.zod.ts` | 5 | 6 |
@@ -178,7 +178,7 @@ over it is here.
178178
| `touch.zod.ts` | 7 | 7 |
179179
| `view.zod.ts` | 6 | 50 |
180180
| `widget.zod.ts` | 9 | 9 |
181-
| **total** | **76** | **198** |
181+
| **total** | **76** | **199** |
182182

183183
| Bucket | Sites |
184184
|---|---|

0 commit comments

Comments
 (0)