Skip to content

Commit b70bb2b

Browse files
authored
Merge branch 'main' into claude/issue-5126-strict-readonly-writes
2 parents b081577 + 4658e57 commit b70bb2b

65 files changed

Lines changed: 5284 additions & 212 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: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
---
2+
"@objectstack/service-analytics": patch
3+
---
4+
5+
fix(service-analytics): the three SQL compilers compare LIKE values literally (#5567)
6+
7+
`$contains` / `$notContains` / `$startsWith` / `$endsWith` build a `LIKE` pattern
8+
around the comparand the author wrote. All three of this package's SQL compilers
9+
concatenated that comparand straight into a wildcard position — no escaping, no
10+
`ESCAPE` clause — so `_` (LIKE's single-character wildcard) and `%` (its
11+
multi-character one) stopped being literals. Measured on real SQLite, over the
12+
rows `x_admin` / `xyadmin` / `off 50% now` / `off 5012 now`:
13+
14+
| `where` | returned | correct |
15+
|----------------------------------|---------------|---------|
16+
| `{name: {$contains: '_admin'}}` | `['1','2']` | `['1']` |
17+
| `{name: {$contains: '50%'}}` | `['3','4']` | `['3']` |
18+
| `{name: {$startsWith: 'x_'}}` | `['1','2']` | `['1']` |
19+
| `{name: {$endsWith: '0% now'}}` | `['3','4']` | `['3']` |
20+
21+
Every row is a **widening** — rows the author excluded came back — and
22+
`$notContains` is the mirror image, excluding rows the author kept. One of the
23+
three call sites is the ADR-0021 D-C read-scope (tenant + RLS) lowering, where a
24+
wider predicate is over-reach rather than a loose filter (the #5347 / #5324
25+
ruling on that same file). Prime Directive #3 forces machine names to
26+
`snake_case`, so essentially every machine-name comparand carries a `_` and hit
27+
this silently.
28+
29+
All three compilers now escape the comparand and bind an explicit
30+
`ESCAPE` argument, matching what `driver-sql`'s `applyLike` has always done — so
31+
the same filter selects the same rows whichever strategy answers, and the
32+
`/analytics/sql` echo describes the statement that ran instead of a wider one.
33+
34+
**No authoring change.** A comparand with no `_`, `%` or `\` binds exactly the
35+
pattern it bound before; only its meaning when it *does* carry one changes, from
36+
wildcard to literal. If you were relying on a comparand acting as a wildcard,
37+
that was never a declared capability of these operators — the spec describes them
38+
as substring / prefix / suffix matches — and `driver-sql` already read it
39+
literally, so the reading you got depended on which strategy served the query.
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
---
2+
"@objectstack/runtime": patch
3+
"@objectstack/metadata-protocol": patch
4+
---
5+
6+
fix(runtime): `callData`'s ObjectQL fallback answers a missing record id with 404 `RECORD_NOT_FOUND` (#5138)
7+
8+
`callData` (the data bridge behind `/data`, the MCP bridge and the declarative
9+
endpoint executor) is protocol-first with an ObjectQL fallback. The fallback
10+
gave **three different answers to one fact** — that `id` names no row:
11+
12+
| verb | before | on the wire |
13+
|---|---|---|
14+
| `get` | `return … : null` | `200 { data: null }` |
15+
| `update` | `throw new Error('[ObjectStack] Not Found')` — no `.status` | **500** |
16+
| `delete` | no existence check at all | `200 { deleted: true }` |
17+
18+
The protocol path has answered `404 RECORD_NOT_FOUND` on all three verbs since
19+
#4435 (re-asserted for the batch path by #5088), so the answer to the same
20+
request depended on something no caller can see: whether the deployment
21+
registered the `protocol` slot (`MetadataPlugin` / `@objectstack/metadata-protocol`).
22+
All three fallback branches now throw the SAME envelope the protocol throws.
23+
24+
Two of these were actively harmful. `update` reported a caller mistake as an
25+
internal fault — every dispatcher exit reads `.status``.statusCode` → 500, so
26+
a 4xx fact entered error reporting and alerting as a 5xx. `delete` reported
27+
success for a row that never existed, which is the hardest class to notice: an
28+
integrator reading `200` records the cleanup as done.
29+
30+
The envelope is not re-spelled. `recordNotFoundError` is now exported from
31+
`@objectstack/metadata-protocol` and imported by the fallback, so there is one
32+
construction point and the two paths behind one `callData` cannot drift apart
33+
again.
34+
35+
**Upgrade note.** If you run an assembly WITHOUT the metadata-protocol plugin
36+
(lean hosts, and the MCP multi-env path that threads a raw driver), these three
37+
calls change their answer for a missing id — from `200`/`200`/`500` to `404
38+
{ code: 'RECORD_NOT_FOUND', message: 'Record <id> not found in <object>' }`.
39+
Deployments that DO register the protocol slot are unaffected: they already
40+
answered `404` and this release does not touch that path. A client that
41+
branched on `data === null` from `GET /data/:object/:id` should branch on the
42+
`404` instead; a client that treated `DELETE` as idempotent should treat `404`
43+
as "already gone". Declarative endpoints (`object_operation`) inherit the same
44+
answer, since they reuse `/data`'s delegation.
45+
46+
`delete`'s existence check is a `find` probe, not a read of what `ql.delete`
47+
returned: `IDataDriver.delete` declares `Promise< boolean >` and the protocol
48+
can read it, but `IDataEngine.delete` declares `Promise< any >` and the engine
49+
returns its driver's result through the hook chain — testing that for `false`
50+
would be reading a signal the contract does not promise, and it fails in the
51+
direction this fixes.
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
'@objectstack/service-automation': patch
3+
---
4+
5+
自动化引擎:嵌入式 host 从未调用 `sealNodeTypeVocabulary()` 时,首次执行 flow 会告警一次(#4792)
6+
7+
#4771 把 ADR-0018 的节点类型校验从 `registerFlow` 挪到了 `sealNodeTypeVocabulary()``AutomationServicePlugin``kernel:bootstrapped` 自动 seal,插件路径不受影响;但自己 `new AutomationEngine()` 且从不 seal 的嵌入式 host 就彻底拿不到这项校验,而且完全静默 —— 只有读过 changeset 的人才知道要补一行调用。现在这类 host 在第一次真正执行 flow 时会得到一条 `warn`,说明丢了什么、以及要调用哪个方法。
8+
9+
- 首次执行是最早既安全又必然到达的时点:正在跑 flow 的 host 显然已经装配完毕(否则这次执行本身就会 `NO_EXECUTOR` 失败)。
10+
- **每个引擎实例一次**,不是每进程一次 —— 一个 host 建了多个引擎(按租户/环境各一个是常见形态)就是在每个上都漏了这次调用。
11+
- 告警只报「缺了这次调用」这个关于 host 的事实,**不报**未知节点类型的审计结果:未 seal 的引擎其词汇表按契约仍可增长,在那里断言「某类型没有执行器」正是 #4771 删掉的那种会被本次启动反驳的判断。需要审计结果又不想封闭词汇表的 host 用只读的 `getUnknownNodeTypeAudit()`
12+
-**不会**顺带自动 seal:「谁决定词汇表封闭」只能有一个答案(host)。而且 seal 之后 `registerFlow` 会转为即时校验,自动 seal 会让「先执行、后注册插件执行器」(ADR-0018 允许)的嵌入式 host 开始收到 #4771 那种误报。
13+
14+
`AutomationServicePlugin` 的部署与已显式调用过 `sealNodeTypeVocabulary()` 的 host 都不会多打任何日志(两条哨兵测试守着)。
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
---
2+
"@objectstack/service-automation": patch
3+
---
4+
5+
fix(service-automation): flow 绑定失败的告警改用结构化 `meta`,不再把 Zod issue 数组塞进单行日志 (#5048)
6+
7+
`AutomationServicePlugin` 的五个 flow 绑定/读取失败点都把 `err.message` 插进一条
8+
单行 `logger.warn`。而 `registerFlow``FlowSchema` 解析,#4001 关闭 metadata
9+
schema 之后未知键是**抛出**而不是被丢弃 —— ZodError 的 `.message` 是 issue 数组的
10+
多行 JSON dump,第一行就是一个 `[`
11+
12+
两级管线随后把余下内容销毁:`ObjectLogger.write()` 每次调用只写一条
13+
`<ts> <LEVEL> <msg>` 记录,带换行的 message 会溢出到没有等级前缀的后续行;而
14+
`serve` 的启动诊断缓冲(`BootLogCapture.offer()`)只保留 `classifyBootLogLine`
15+
能认出等级前缀的行。于是一次启动里 24 个绑不上的 flow,给出的是 24 条点了名字、
16+
然后说一个 `[` 的告警 —— cloud#971 能横跨整条 rc.1 发布线没人发现,就是因为这个。
17+
18+
现在这些位置改为:message 是不含换行的静态字符串,事实交给 logger 的 `meta`
19+
第二参(仓库里每个 `Logger` 实现都用 `JSON.stringify` 序列化它,值里的换行变成
20+
`\n` 转义,整条记录稳定占一行,正是启动缓冲会保留的形态)。新增内部模块
21+
`flow-bind-diagnostics.ts` 把 Zod issue 摊平成 `{ code, path, message,
22+
unrecognized }`:`path` 渲染成 `nodes[0].config.x`,被拒的键名放在
23+
`unrecognized` 而不是 Zod 原本的 `keys` —— 因为 `ObjectLogger` 的默认脱敏表
24+
(`['password','token','secret','key']`)按**子串**递归匹配,`keys``key`,
25+
原样转发 `err.issues` 会渲染成 `"keys":"***REDACTED***"`,恰好丢掉读者唯一需要
26+
的那个事实。issue 列表有上限,超出时用 `issueCount` **显式声明**总数,而不是静默
27+
截断。非 ZodError 的失败退回 `error` 字符串分支。
28+
29+
无公开 API 变化;日志文本的可 grep 前缀(`cold-boot flow bind: failed to
30+
register``flow re-sync: failed to register``flow pull from ObjectQL
31+
registry failed``flow read from protocol failed`)全部保留。与 #4632 同源:
32+
被截断的诊断比没有诊断更贵。
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
---
2+
"@objectstack/lint": minor
3+
---
4+
5+
feat(lint): the react-page publish gate PARSES `ChartAggregateSchema` instead of re-deriving it (#5020)
6+
7+
`<ObjectChart aggregate={{…}}>` is judged at publish time by
8+
`validate-react-page-props`. That gate used to RE-DERIVE the aggregate's
9+
declaration: a local `CHART_FUNCTIONS` copy of the function vocabulary and a
10+
hand-written twin of the schema's count/field refinement. Two implementations of
11+
one contract, each free to drift — and, because unknown-key handling is a
12+
property of a **parse** rather than of a list of `if`s, a gate with no
13+
unknown-key check at all. The rule now calls `ChartAggregateSchema.safeParse()`
14+
on a statically resolvable literal, exactly as #5022 did for
15+
`ChartDrillDownSchema` beside it, and both hand-derived copies are deleted:
16+
`@objectstack/spec` is the single source of the vocabulary and the refinement
17+
again.
18+
19+
**Newly reported (all `error`, all previously silent).** These are shapes the
20+
schema, the published react-blocks type and objectui's renderer already agreed
21+
were wrong; the old gate simply could not see them:
22+
23+
| authored | before | after |
24+
|---|---|---|
25+
| `aggregate={{ field: 'total', groupBy: 'status' }}` (no `function`) | accepted | `aggregate.function: Invalid option: expected one of "count"\|"sum"\|"avg"\|"min"\|"max" (nothing is set there)` |
26+
| `aggregate={{ field: 42, function: 'sum', groupBy: 'status' }}` | accepted | `aggregate.field: Invalid input: expected string, received number` |
27+
| `aggregate={{ function: 'count', groupBy: 42 }}` | accepted | `aggregate.groupBy: Invalid input (received 42) — no accepted form matched: (1) … (2) …` |
28+
| `aggregate="count"` / `aggregate={[]}` | accepted | `aggregate must be a configuration object, not string.` |
29+
30+
**Re-worded, same verdict.** Two messages now arrive from the schema rather than
31+
from this rule's own copy. If you match on lint output, update the text:
32+
33+
- FROM `aggregate.function "median" is not an aggregation this chart can run.`
34+
(hint: `Use one of: count, sum, avg, min, max.`)
35+
TO `aggregate.function: Invalid option: expected one of "count"|"sum"|"avg"|"min"|"max" (received "median")`
36+
— the vocabulary is the enum's own, and the author's value is echoed back from
37+
the input (the one part zod does not put in the message).
38+
- FROM `aggregate.function "sum" has no "field" to aggregate.`
39+
TO `aggregate.field: aggregate.function "sum" needs a "field" to aggregate (only "count" may omit it).`
40+
— verbatim from the schema's refinement.
41+
42+
The rule id (`react-chart-aggregate-invalid`) and the severity are unchanged for
43+
both.
44+
45+
**`aggregate.groupBy` missing is a NEW `warning`, deliberately not an error.**
46+
It is the one violation the platform does not agree with itself about:
47+
`ChartAggregateSchema` and the published react-blocks type both declare `groupBy`
48+
**required**, while objectui's `ObjectChart` honours its absence
49+
(`schema.aggregate?.groupBy || schema.xAxisKey`) and this protocol's own
50+
`chartAggregateCategoryKey` documents the ungrouped single-row result. Gating it
51+
would break a working authoring shape to enforce a declaration the platform does
52+
not keep, so the finding explains the situation and does not fail
53+
`os lint`/`validate`/`compile`. Whether the schema loosens or the renderer
54+
tightens is decided on #5583.
55+
56+
**What this does NOT fix yet.** `ChartAggregateSchema` and `ChartGroupBySchema`'s
57+
object arm are still STRIP-posture, so the parse this gate now runs **drops** an
58+
unknown key rather than reporting it: `groupby` for `groupBy` and
59+
`dateGranularty` for `dateGranularity` still degrade a chart to one ungrouped
60+
point with the build green. Wiring the parse is the precondition for closing
61+
that, not the closing — `.strict()` is a property of a parse, and until now there
62+
was no parse to make strict. The spec-side tightening is **#5583**; the tolerance
63+
is pinned by name in this rule's tests so a wired gate cannot be mistaken for a
64+
closed one (#4583).
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
---
2+
"@objectstack/rest": patch
3+
---
4+
5+
fix(rest): an unclassified route error answers a sanitised 500, not a 400 (#5489)
6+
7+
**升级须知 — 状态码行为变化。** `@objectstack/rest` 的错误映射 `mapDataError`
8+
在所有分类分支都不匹配时,原先的终局兜底是
9+
`{ status: 400, body: { error: <原始 message> } }`。这一支现在改为一个消毒过的
10+
服务端故障信封:
11+
12+
```
13+
500 {"error":"Internal server error","code":"INTERNAL_ERROR"}
14+
```
15+
16+
**为什么。** 400 的语义是「你请求错了」——SDK、fetch 封装、代理和重试策略都据此
17+
判定「不要重试,调用方得改点什么」。而真正落到这一支的错误恰恰相反:元数据存储
18+
读不到时 `matchEndpoint` 按契约抛错(它抛就是为了让 outage 不伪装成「没有声明
19+
任何 endpoint」,ADR-0110 D3),或者干脆是处理器自身的 `TypeError`。两者调用方都
20+
修不了,且都**应该**重试。实测:`GET /api/v1/meta/api` 对着一个抛
21+
`Error('metadata store unreachable')` 的存储,返回 HTTP 400。
22+
23+
同时,原始 message 是逐字下发的——而这偏偏是全文件里最没有证据表明可以下发的一
24+
条路径:走到这里的前提就是 `looksLikeInternalErrorLeak` 什么都没匹配上,而
25+
#5462 已经记过「关键词启发式沉默不等于安全」。实测到的一例:一个声明了
26+
`status: 502`、message 为 `connect ECONNREFUSED 10.0.0.5:5432 (internal pool)`
27+
的错误,经由数据路由直接调用 `mapDataError` 时,以 400 携带主机与端口下发。
28+
沿用 #5464 的纪律:原文进服务端日志,不进客户端(500 不在
29+
`isExpectedDataStatus` 内,`handleRouteError` 会打印完整错误对象)。
30+
31+
**真正的客户端错误一个都没有改变。** 改动前先做了测绘:给这一支加桩,跑完
32+
`@objectstack/rest` 全套(48 文件 / 719 用例),落到这一支的只有 6 个错误——本单
33+
的存储 outage、两个 502 的 ECONNREFUSED、三个 `TypeError`,没有一个是客户端
34+
错误。历史上唯一骑在这条兜底上的客户端错误家族(driver-sql 无法编译的 filter
35+
拒绝)已由 #4436**生产者侧**声明 `status: 400` + `INVALID_FILTER` 迁走。
36+
validation / permission / unknown object / unknown field / not-null 漂移 /
37+
unique 冲突 / 沙箱业务拒绝等全部仍由各自分支给出原本的 4xx。
38+
39+
**`INTERNAL_ERROR` 而非 `DATABASE_ERROR`** #5462`DATA_STORE_FAULT`
40+
(`500 DATABASE_ERROR`)用在证据**指名**了存储故障的地方(驱动的 missing-relation
41+
措辞、`looksLikeInternalErrorLeak` 命中);而这一支的定义性事实是「没有任何证据」,
42+
把处理器的 `TypeError` 报成 `DATABASE_ERROR` 会把运维指向一个其实健康的数据库。
43+
`INTERNAL_ERROR``standardErrorCodeForHttpStatus(500)` 的取值
44+
(`@objectstack/spec``HttpStatusErrorCodeMap`)——目录自己为「500 且无更具体
45+
code」定义的下限,不是第三套措辞;message 复用的也是
46+
`resolveErrorResponse` 声明式 5xx 分支已在用的 `INTERNAL_ERROR_MESSAGE`
47+
48+
**如果你的客户端把这条兜底当 400 处理过**:它现在是 5xx,可以重试;若你有生产者
49+
依赖「不声明 status 即可把 message 原文送达调用方」,请改为在抛出点声明
50+
`status``code`(契约优先),那是唯一仍会把措辞交给调用方的路径。
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
---
2+
'@objectstack/cli': patch
3+
---
4+
5+
fix(cli): `OS_STORAGE_ROOT` now actually takes effect — renamed to `OS_STORAGE_LOCAL_ROOT`, the name the settings service reads (#4968)
6+
7+
The CLI and the settings service spelled the local storage root differently.
8+
The CLI wrote its own invented name, `OS_STORAGE_ROOT`; the settings service
9+
derives the env name for the same value from the namespace it owns —
10+
`envKeyOf('storage', 'local_root')` = `OS_STORAGE_LOCAL_ROOT` — and nothing in
11+
the repo ever set that. So the two channels never met: `os serve` constructed a
12+
local adapter at the root the operator named, `StorageServicePlugin` re-resolved
13+
from settings at `kernel:ready`, found only the manifest's **schema default**,
14+
and swapped the adapter to `./.objectstack/data/uploads`.
15+
16+
`OS_STORAGE_ROOT` therefore took effect for exactly one value — the one that
17+
happens to equal that default — which is why plain `pnpm dev` never showed it.
18+
Every other value was constructed and then discarded:
19+
20+
- **Production**: `OS_STORAGE_ROOT=/srv/uploads` was ignored and uploads landed
21+
under the process cwd. An operator following `backup-restore.mdx` backed up an
22+
empty directory.
23+
- **`dev --fresh`**: the tempdir was documented to own all state for the run;
24+
uploads actually went to the project cwd and survived process exit.
25+
- Every clean boot logged a data-loss-grade "adapter swapped … existing files
26+
were NOT migrated" warning. That warning was **accurate** — the swap really
27+
happened — and is untouched here. It stops firing because the swap stops.
28+
29+
The fix is at the producer, not as a tolerant read in the consumer: `dev.ts`
30+
publishes `OS_STORAGE_LOCAL_ROOT`, and `serve.ts` resolves the root through one
31+
channel (`resolveStorageLocalRootEnv`), shared with `os migrate`'s storage
32+
bootstrap so the CLI materialises bytes exactly where the server would.
33+
34+
`OS_STORAGE_ROOT` keeps working for **one release** via
35+
`readEnvWithDeprecation('OS_STORAGE_LOCAL_ROOT', 'OS_STORAGE_ROOT')`, warning
36+
once per process, and is then removed. When the legacy name supplies the value
37+
it is also stamped onto the canonical name, because the settings service only
38+
ever looks up `OS_STORAGE_LOCAL_ROOT` — without the stamp a deployment on the
39+
old spelling would keep the original defect in full.
40+
41+
Storage settings now resolve `source: 'env'` at the value the adapter was built
42+
with, so Setup → Settings → File Storage shows the directory actually in use.
43+
No change to `packages/services/service-storage` — the swap predicate is correct
44+
and stays as is.

0 commit comments

Comments
 (0)