Skip to content

Commit ca3c406

Browse files
committed
Merge origin/main into claude/issue-5897-hydration-outage-diagnostic
2 parents fab760f + 2381c19 commit ca3c406

54 files changed

Lines changed: 4097 additions & 182 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
---
2+
"@objectstack/service-analytics": patch
3+
---
4+
5+
fix(service-analytics): thirteen caller-shaped analytics refusals answer 4xx from their own envelope instead of `500` (#5716)
6+
7+
**Observable behaviour change — read this if you alert, retry, or assert on status.**
8+
Thirteen refusal conditions in `service-analytics` (twelve `throw` sites — the
9+
cross-object measure and filter share one) used to reach the caller as
10+
`500 {"code":"ANALYTICS_QUERY_FAILED"}` on `POST /analytics/dataset/query`, and as
11+
`500 {"code":"INTERNAL_ERROR"}` on `POST /analytics/query`. They now answer **400**
12+
`DATASET_INVALID` for the seven that are a verdict about the dataset or the whole
13+
selection, `INVALID_FIELD` for the six that name one member of the request:
14+
15+
| refusal | now |
16+
|---|---|
17+
| dataset JOIN crosses datasources (#5115) | `DATASET_INVALID` / 400 |
18+
| `include` names a relationship the object does not have | `DATASET_INVALID` / 400 |
19+
| `include` path past the 3-hop limit | `DATASET_INVALID` / 400 |
20+
| a `dateRange` bound that is not a date | `DATASET_INVALID` / 400 |
21+
| `compareTo` names a timeDimension with no `dateRange` | `DATASET_INVALID` / 400 |
22+
| `compareTo` with no dated window to shift | `DATASET_INVALID` / 400 |
23+
| `compareTo` ambiguous between two dated windows | `DATASET_INVALID` / 400 |
24+
| cube declares no such measure (#4157) | `INVALID_FIELD` / 400 |
25+
| ObjectQL: cross-object time-dimension bucket | `INVALID_FIELD` / 400 |
26+
| ObjectQL: cross-object measure | `INVALID_FIELD` / 400 |
27+
| ObjectQL: cross-object filter | `INVALID_FIELD` / 400 |
28+
| ObjectQL: multi-hop cross-object dimension | `INVALID_FIELD` / 400 |
29+
| ObjectQL: non-recombinable measure over a cross-object dimension | `INVALID_FIELD` / 400 |
30+
31+
Monitoring that counted these as server errors will see a 5xx disappear and a 4xx
32+
appear, and a client retrying on 5xx will stop retrying a request that cannot
33+
succeed until the request or the dataset changes. **No refusal condition moved and
34+
no message was reworded** — the same inputs are refused, in the same words; only
35+
the envelope is new. (The messages are load-bearing beyond readability: #5923's
36+
tests assert the `planCrossObject` wording, and #5717 tracks one compiler message
37+
for colliding with a downstream sniffer.)
38+
39+
## What was wrong
40+
41+
#5352 gave the dataset route a list of message SUBSTRINGS so six refusal families
42+
could answer 400, and #5367 retired five of those entries by giving their
43+
producers an ADR-0112 envelope. Both rounds worked from that list — and the list
44+
was only ever the refusals someone had already hit. Reading every `throw` in the
45+
package afterwards found thirteen more of exactly the same kind, which had never
46+
been on it: a typo in `compareTo`, a `dateRange` the dashboard sent, a dataset
47+
whose `include` names a relationship that does not exist. Each answered "the
48+
platform is broken" for a mistake the caller or the author could fix, on both
49+
analytics faces.
50+
51+
**Both faces move, measured.** `/analytics/dataset/query` reads the envelope in
52+
its catch (#5352); `/analytics/query` exits through
53+
`dispatcher-plugin.errorResponseBase`, which already adopts a thrown `status` and
54+
carries the `code` (#3867/#3842) — so the cross-object refusals go from
55+
`500 INTERNAL_ERROR` to `400 INVALID_FIELD` there as well, without touching that
56+
route. The open question #5811 tracks on that face is about *withholding the
57+
message of a declared 5xx*, which none of these are.
58+
59+
## Why two codes
60+
61+
`dataset-refusal.ts` gains a second constructor, `invalidMemberError`
62+
(`INVALID_FIELD` / 400 + `member`/`param`/`cube`), beside `datasetInvalidError`.
63+
The split is by what the refusal is a verdict ABOUT: the dataset/selection as a
64+
whole, or one member the request named. The member family is `INVALID_FIELD`
65+
because the three shipped analytics gates already answer exactly that for the
66+
NEIGHBOURING member-level mistakes on the same request keys — `measures` (#4437),
67+
`dimensions`/`timeDimensions` (#5520), `where` (#5669) — so one class of mistake
68+
keeps one wire shape; and because these six fire on `/analytics/query` too, where
69+
there is no dataset for `DATASET_INVALID` to be about. No new code is registered:
70+
both are already in the ADR-0112 vocabulary.
71+
72+
## What deliberately did NOT change
73+
74+
`native-sql-strategy`'s "measure … has unrecognised type" stays a bare `Error`
75+
(an undeclared 500) although #5716 listed it as author-shaped. Measured:
76+
`Metric.type` is the closed `AggregationMetricType` enum, `metric-type-coverage.test.ts`
77+
pins that the strategy handles every member of it, the dataset compiler writes
78+
only `SUPPORTED_AGGREGATES` into a cube, and `inferMeasure` mints six known types
79+
— so no spec-valid cube can reach it. An arrival is our own drift or a host
80+
registering an unparsed cube, and blaming the caller would hide a platform fault
81+
from 5xx alerting. The two "Cube not found" guards and the two operator-drift
82+
throws stay bare for the same reason.
83+
84+
Coverage: `unlisted-refusal-envelope.test.ts` (service-analytics) drives all
85+
thirteen refusals through the real producers — one block pinning that the refusal
86+
SET and its wording are unchanged, one pinning the envelope, one pinning the
87+
verdicts that stay 500; `analytics-dataset-unlisted-refusal-envelope.test.ts`
88+
(rest) drives eleven of them end-to-end through the route with a real
89+
`AnalyticsService`, plus three positive controls and the two sites that route
90+
cannot reach (with the measurement that explains why).
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
---
2+
"@objectstack/service-datasource": minor
3+
---
4+
5+
fix(service-datasource): a `pool` block on a sqlite datasource is rejected, not dropped in silence (#5714)
6+
7+
`datasource.pool` is declared, strict and documented, and until now it reached a
8+
driver only from the arms that build a pooled client: `postgres` / `mysql` hand
9+
`buildSqlPool(spec)` to `SqlDriver`, `mongo` maps `min`/`max` onto the client's
10+
`minPoolSize`/`maxPoolSize`. The `sqlite` and `sqlite-wasm` arms passed no pool
11+
at all — `resolveSqliteDriver` has no such option and `SqliteWasmDriver` does
12+
not take one — so an author who sized their pool got the driver's own single
13+
connection and nothing said otherwise. Measured through the real factory:
14+
15+
```text
16+
sqlite + pool{min:3,max:9} knex.client.config.pool {"createTimeoutMillis":15000} live {min:1,max:1}
17+
postgres + pool{min:3,max:9} knex config.pool {"min":3,"max":9} live {min:3,max:9}
18+
```
19+
20+
`examples/app-crm` was the live specimen: `CrmDatasource` asked for
21+
`{ min: 1, max: 5 }` and ran on one connection.
22+
23+
**Wiring it through would be wrong, not merely more work.** Knex's
24+
better-sqlite3 dialect pins `{min:1,max:1}` on purpose: every pool acquire runs
25+
`new Database(filename)`, so two connections to `:memory:` are two separate,
26+
mutually invisible databases. Honouring `max: 5` there would split one
27+
datasource's data across five stores. Sizing a SQLite pool is not a knob the
28+
platform can offer, so the declaration is rejected at authoring/publish instead
29+
— Prime Directive #12: fix the metadata at the producer, reject it loudly, never
30+
tolerate it in the consumer.
31+
32+
**Observable behaviour change — read this if any datasource declares `pool`.**
33+
A `sqlite` / `sqlite-wasm` datasource carrying a `pool` block now **fails**
34+
where it used to boot with the block ignored:
35+
36+
- **Boot** (`DatasourceConnectionService.connectDeclared`) refuses before a
37+
single connection is attempted, naming every offending datasource in one
38+
throw. Every *declared, active* datasource is judged, including the ones the
39+
ADR-0062 D2 gate leaves unconnected — a pool block on a datasource nobody
40+
connects is exactly as dropped as a connected one's. `active: false` is
41+
skipped, so switching a datasource off remains the way out.
42+
- **Setup → Datasources** (`createDatasource` / `updateDatasource`) rejects the
43+
draft before the record is stored. An update that touches neither `pool` nor
44+
`driver` is not re-judged, so a record written before this gate stays editable
45+
— including the `active: false` that takes it out of service.
46+
- **The driver factory** (`createDefaultDatasourceDriverFactory`) rejects it as
47+
the last door, for hosts that build drivers directly.
48+
49+
The fix is to delete the block: `pool` is a no-op on SQLite either way, so
50+
removing it changes nothing about how the datasource runs.
51+
52+
```diff
53+
export const CrmDatasource = defineDatasource({
54+
name: 'crm_primary',
55+
driver: 'sqlite',
56+
config: { filename: ':memory:' },
57+
- pool: { min: 1, max: 5 },
58+
active: true,
59+
});
60+
```
61+
62+
`pool` is unchanged and still honoured on `postgres` / `mysql` / `mongo`, and a
63+
plugin-contributed driver id (`com.vendor.snowflake`) is not judged at all —
64+
the same boundary the `datasource.config` gate draws in #4410: the platform
65+
validates what it can construct.
66+
67+
This verdict is an **authoring** error, not a connect failure: it never goes
68+
through the ADR-0062 D5 degradation path, so `OS_ALLOW_DRIVER_CONNECT_FAILURE`
69+
does not apply to it and is not suggested. That hatch exists for a database that
70+
is unreachable — a fact about the world that may resolve itself. A `pool` the
71+
driver cannot read is a fact about the metadata.
72+
73+
Hosts that inject their own driver factory can hold the same contract with the
74+
newly exported `assertDatasourcePoolSupported` / `driverReadsDeclaredPool` /
75+
`unsupportedPoolIssue` / `POOL_UNSUPPORTED_DRIVER_IDS`.
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
---
2+
"@objectstack/formula": patch
3+
---
4+
5+
fix(formula): `matchesFilterCondition``$exists` 改读「有值」,与 `$null` 成严格互补
6+
7+
**行为变更,影响 RLS 写侧 `check` 的判定。** `{ x: { $exists: true } }`
8+
`{ x: null }` 以前答 `true`(键存在),现在答 `false`(没有值)。
9+
10+
`matchesFilterCondition` 是 RLS `check` 子句(insert/update 的 post-image)的求值器 ——
11+
写路径上没有查询可以下推,只能逐记录判定。它此前把 `$exists` 读成「键是否存在」
12+
(`actual !== undefined`),而 `driver-sql` 一直把同一个算子编译成 `IS NOT NULL`
13+
于是同一条规则里的 `$exists`,写侧放行的记录读侧看不见。
14+
15+
2026-08-06 裁定取「有值」,理由是另一种读法在最要紧的地方**无法兑现**:SQL 里列
16+
**就是** schema,一行不可能「缺一个键」,所以 `driver-sql` 除了 `IS NOT NULL` 别无
17+
可编译的东西。字段的存在性是 **schema** 的属性,不是**记录**的属性;spec 若声明
18+
「键是否存在」,就是在承诺两个后端永远交付不了的语义。因此 `driver-sql` 的发射器
19+
一字未动,移动的是本求值器。
20+
21+
对齐之后 `$exists``$null` 在每个后端上都是严格互补:
22+
`$exists: true``$null: false`,`$exists: false``$null: true`
23+
「键缺失」与「值为 null」在这里是同一个事实 —— 这也正是 `getPath` 对两者本来就
24+
返回同一个 `undefined` 的原因。
25+
26+
`$ne` / `$nin` / `$notContains` / `$null` 四个算子本来就是本次裁定的目标语义,
27+
一字未改。
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
---
2+
'@objectstack/objectql': patch
3+
---
4+
5+
lifecycle: the Archiver's batch loop now honours the teardown abort bit (#4747)
6+
7+
`LifecycleService.stop()` raises an abort bit that the sweep checks at each leg
8+
boundary — between objects, and (since #5753) between reap pages. The Archiver's
9+
own batch loop did not check it, so teardown landing mid-archive still ran the
10+
remaining batches out: up to 20 × 500 rows of `find` + per-row `upsert` +
11+
`bulkDelete` issued across two datasources the host is already closing.
12+
13+
It now breaks at the batch boundary like the reap loop does. The "hot-delete only
14+
what the cold store took" safety rule is unaffected: the batch in flight finishes
15+
its `upsert``bulkDelete` pair, and batches not yet begun are left for the next
16+
sweep to re-read.
17+
18+
Not reachable in any of today's deployments — the Archiver only engages once an
19+
object declares `archive` with a provisioned cold datasource, and no platform
20+
object does. This is prevention for the first deployment that declares one.
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
'@objectstack/spec': patch
3+
---
4+
5+
`check:react-declaration-parity` had no path on which it could go red — a missing manifest now fails instead of skipping, and the ledger says "cannot run here" instead of "deliberately not run".
6+
7+
Two things stacked. The gate was in **no workflow** (`grep -rl check:react-declaration-parity .github/workflows/` returned nothing), and a manual run without `MANIFEST` printed `⚠ manifest unavailable … — skipping.` and **exited 0**. So the one gate whose history is written up in AGENTS.md as worth keeping — `spec-only` / `registry-only` / `missing` are real signals — could not fail for anybody, ever. Two other issues had already started citing it as the negative example of a gate that cannot be shown to catch anything (#4804 / #4777).
8+
9+
**Where the manifest comes from, measured before anything was wired.** The right-hand side is objectui's `sdui.manifest.json`, and this repository cannot produce one: its only producer drives a real browser at objectui's built console and reads `window.__MANIFEST` (the registry pulls browser-only deps, so nothing enumerates it from Node). `packages/console/dist/` is gitignored, `scripts/build-console.sh` deliberately does not dump one — it must not drag a browser into the console build — and the published `@objectstack/console` tarball contains no `sdui.manifest.json` either (16.1.0: 513 files, zero `sdui` matches; its `dist/manifest.json` is the PWA manifest, so even the CLI's `@objectstack/console/dist/sdui.manifest.json` fallback resolves to nothing). Wiring a step into `lint.yml` would therefore have wired a **permanently skipping** step — the same defect with CI decoration.
10+
11+
- **"Could not run" is now a failure, not a skip.** No `MANIFEST`, a path that does not exist, malformed JSON, or a dump declaring zero components each exit **1** with a prescription that names the producer (`pnpm sdui:manifest`, `OBJECTUI_ROOT=../objectui pnpm objectui:build`) and the browser it needs. Deliberately independent of `--strict`: that flag prices a *divergence*, and this is the other thing entirely — no comparison happened. The empty-dump case is new coverage in the same family; objectui's dumper already refuses to *write* one, and this refuses to *read* one instead of reporting every block as missing.
12+
- **The ledger stops implying someone runs it.** `check:generated` moves it out of `NO_GENERATOR` ("runnable, deliberately not run here") into a new `EXTERNAL_INPUT_REQUIRED` bucket that records the missing input and its producer, and prints `cannot run here: check:react-declaration-parity — needs MANIFEST=…; runs in scripts/gen-sdui-manifest.sh`. Following `EXPLICIT_GENERATORS` (#5358/#5807), the classification carries an enforced claim rather than a label: the reconciliation fails if `scripts/gen-sdui-manifest.sh` stops **invoking** the gate on a non-comment line — a gate filed as "runs elsewhere" while running nowhere is the exact hole this bucket exists to expose.
13+
- **The gate is now demonstrably able to fail.** `check-react-blocks-declaration-parity.test.ts` asserts exit codes, not just report text: a fabricated registry-only input and a vanished block each exit non-zero **naming themselves**, all four "could not run" paths exit non-zero, and an accepted state still exits 0 so the red is discriminating. The pre-existing helper swallowed exit codes by design, so every earlier test would have passed against a script that always exited 0 — which is what this gate was.
14+
15+
Where the manifest should come from in CI (an objectui clone plus a browser in this repo's workflows, a published manifest artifact, or a Node-side dump in objectui) stays an open provenance decision, filed separately. Until it is answered, the honest state is a gate that runs at `pnpm sdui:manifest` and refuses to pretend otherwise.
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
---
2+
"@objectstack/service-analytics": patch
3+
---
4+
5+
fix(service-analytics): read scope 的 `$ne` / `$nin` / `$notContains` 改为 NULL-safe,与写侧 `check` 对齐
6+
7+
**这是一次安全相关的行为变更,涉及分析查询的可见行集合。**
8+
read scope 里的 `{ stage: { $ne: 'won' } }` 以前**不返回** `stage IS NULL` 的行,
9+
现在**返回**它们。`$nin` / `$notContains` 同理。
10+
11+
`read-scope-sql.ts` 是 RLS / 租户 read scope 降解成 SQL 的唯一通道(ADR-0021 D-C)。
12+
它此前把这三个算子编译成裸的 `col <> ?` / `col NOT IN (…)` / `col NOT LIKE ?`,
13+
而 SQL 是三值逻辑:被比较列为 NULL 时谓词是 UNKNOWN,`WHERE` 只保留 TRUE,于是
14+
「该列没有值」的行被整批丢掉。
15+
16+
**为什么必须与 `driver-sql` 同一个 PR 落地,而不是排到下一批。** 同一条 RLS 规则被
17+
写一次、在**两侧**求值:读路径由本文件降解成 SQL,写路径由 `formula`
18+
`matchesFilterCondition` 逐记录求值。`formula` 一直用两值 JS(`undefined !== 'won'`
19+
为真)返回这些行。只对齐其中一侧,得到的不是「更小的修复」,而正是那个缺陷本身 ——
20+
一条权限规则准入两个不同的行集,写侧允许的记录读侧看不见。
21+
22+
```sql
23+
-- 之前
24+
"t"."stage" <> ?
25+
"t"."stage" NOT IN (?)
26+
"t"."stage" NOT LIKE ? ESCAPE ?
27+
-- 现在
28+
("t"."stage" IS NULL OR "t"."stage" <> ?)
29+
("t"."stage" IS NULL OR "t"."stage" NOT IN (?))
30+
("t"."stage" IS NULL OR "t"."stage" NOT LIKE ? ESCAPE ?)
31+
```
32+
33+
括号不是排版:`compileField` 用裸 ` AND ` 连接同一字段的多个算子,不加括号的
34+
`col IS NULL OR …` 会比那个 AND 结合得更松,从而**静默放宽整条 scope**
35+
36+
`driver-sql` 一样统一用 OR 展开而非方言等价物(`NOT LIKE` 没有对应形式;SQLite
37+
写法依赖本仓不锁定的引擎版本;实测执行计划相同)。正向比较逐字符不变,
38+
`$ne: null` 仍是 `IS NOT NULL`(空值谓词,不是比较)。
39+
40+
`$not` 路径的逐叶守卫(#5146 / #5326)按原样保留,两条路径读同一张极性表。
41+
`filter-normalizer`(Cube 面)不在本次范围内,归本裁决第二批。

0 commit comments

Comments
 (0)