Skip to content

Commit 9fbb4f7

Browse files
authored
Merge branch 'main' into claude/issue-5515-sync-arch-l3-sap
2 parents fb7426b + 4658e57 commit 9fbb4f7

136 files changed

Lines changed: 9950 additions & 620 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: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
---
2+
"@objectstack/service-analytics": patch
3+
---
4+
5+
fix(service-analytics): only a canonical numeric spelling is recovered as a number, so `'007'` / `'1.50'` stay strings (#5528)
6+
7+
An analytics `where` round-trips every comparand through the internal
8+
`values: string[]` form — `stringifyForCube` on the way out, and
9+
`coerceFilterValueForSql` / `coerceFilterValueForObjectQL` on the way back. The
10+
decoder decided "this is a number" from the string's **shape** alone
11+
(`/^-?\d+(\.\d+)?$/`), which cannot distinguish a number that was stringified on
12+
the way out from a string the author actually wrote.
13+
14+
Measured before the fix, on cube `orders` / TEXT column `code`:
15+
16+
| author's `where` | leaf `values` | SQL bind | engine comparand |
17+
|---|---|---|---|
18+
| `{code: {$eq: '007'}}` | `["007"]` | `7` | `7` |
19+
| `{code: {$eq: '0912'}}` | `["0912"]` | `912` | `912` |
20+
| `{code: {$eq: '1.50'}}` | `["1.50"]` | `1.5` | `1.5` |
21+
22+
Both consumers were affected: the raw-SQL bind in `NativeSQLStrategy` and the
23+
comparand handed to the ObjectQL aggregate engine.
24+
25+
The failure was **silent and mis-targeted, not empty**. Against a text column
26+
SQLite applies the column's affinity to the integer bind, so a widget filtered on
27+
order number `'007'` returned the row storing `'7'` — a different row, with no
28+
error to read; on Postgres the same query is a `text = integer` type error, and on
29+
the engine path the strict comparison simply matched nothing (measured: 0 rows).
30+
Zero-padded and trailing-zero strings are ordinary business shapes — order
31+
numbers, work orders, SKUs, dialling codes, postcodes, `'1.50'` prices.
32+
33+
Recovery is now limited to a number's **own canonical spelling**
34+
(`String(Number(s)) === s`):
35+
36+
- a comparand that really was a number is `String(n)` by construction, so it
37+
still round-trips — `7``'7'``7`, `1.5``'1.5'``1.5`, `-3``-3`;
38+
- a string `Number()` would rewrite — `'007'`, `'0912'`, `'1.50'`, `'1.0'`,
39+
`'-0'`, or more digits than a double holds — cannot have come from a number, so
40+
it stays the string the author wrote.
41+
42+
The narrowing can only ever **remove** recoveries: the shape regex still runs
43+
first, so `'1e3'`, `'1e+21'`, `'+7'`, `' 7'`, `'0x10'`, `'Infinity'` and `'NaN'`
44+
were strings before this change and are strings after it. This also aligns with
45+
ADR-0053 D-A2, which demoted this textual type re-derivation to a last resort
46+
behind the driver-backed `coerceTemporalFilterValue` hook.
47+
48+
**Stopgap, and named as one.** `values: string[]` still has no escape, so the
49+
author strings `'null'` / `'true'` / `'false'` still collide with the tokens the
50+
encoder writes for the real `null` and booleans. Making the round trip lossless —
51+
tagged values, or an `unknown[]` internal representation — is #5526; the
52+
collision is pinned as unchanged in
53+
`src/__tests__/filter-value-canonical-number.test.ts` so it is not mistaken for
54+
fixed.
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
---
2+
"@objectstack/service-analytics": patch
3+
---
4+
5+
fix(service-analytics): `contains` 以规范算子 `$contains` 送进引擎,比较值不再落进正则位置(#5557)
6+
7+
`ObjectQLStrategy.convertFilter` 在同一个 `switch` 里处理 LIKE 家族的四个算子。
8+
其中三个(`notContains` / `startsWith` / `endsWith`)自 #4128 起就是规范 spec 算子,
9+
只有 `contains``{ $regex: values[0] }` —— 比较值**原样**放进一个正则位置,不转义。
10+
11+
实测(修复前 → 修复后,引擎收到的 filter):
12+
13+
| `where` | 修复前 | 修复后 |
14+
|---|---|---|
15+
| `{stage: {$contains: 'a.b'}}` | `{stage: {$regex: 'a.b'}}` | `{stage: {$contains: 'a.b'}}` |
16+
| `{stage: {$notContains: 'a.b'}}` | `{stage: {$notContains: 'a.b'}}` | 不变 |
17+
| `{stage: {$startsWith: 'a.b'}}` | `{stage: {$startsWith: 'a.b'}}` | 不变 |
18+
| `{stage: {$endsWith: 'a.b'}}` | `{stage: {$endsWith: 'a.b'}}` | 不变 |
19+
20+
三条后果,都是作者没有要求过的行为,且都不依赖 #4706`$regex` 语义的裁决:
21+
22+
1. **`$regex` 不在契约里。** `filter.zod.ts``FILTER_OPERATORS` 声明 15 个算子,
23+
没有 `$regex` —— 这是**生产方**在发送 schema 未声明的算子。按 Prime Directive #12
24+
修生产方(一个 `case` 标签),而不是给消费方加宽容。
25+
2. **同一棵过滤树在同包两个消费方之间不通。** `read-scope-sql.ts`
26+
`compileScopedFilterToSql` 也是一个 `FilterCondition` 消费方,`compileOperator`
27+
`default` 是 fail-closed,于是它对本策略产出的 filter 直接抛
28+
`unsupported operator "$regex" … (fail-closed)`
29+
3. **行结果取决于哪个驱动来答。**`$regex` 当真正则求值的后端(driver-memory 的
30+
`memory-matcher.ts` 就是,而且是有意为之 —— 服务 plugin-auth 的 ObjectQL adapter)
31+
`a.b` 读成「a、任意一个字符、b」,于是 `axb` 也被匹配上;而 `50% (+)` 作为正则
32+
根本编译不过(`Nothing to repeat`),`catch` 之后 `return false` —— 一个**有匹配行**
33+
的筛选器静默返回零行,作者那边只看到「无数据」。同一个 `$contains` widget 在
34+
`driver-sql` 上则被编译成子串 LIKE:同一张 dashboard,不同驱动,不同行集。
35+
36+
`filter-normalizer.ts``MONGO_TO_CUBE_OP` 只把 `$contains` 映到 `contains`,
37+
别无来源,所以这里回送 `$contains` 就是作者自己那个 key 的往返。
38+
39+
**测试**(`objectql-contains-canonical-operator.test.ts`,新增):引擎 filter 的算子键
40+
逐个对 `filter.zod.ts``ALL_OPERATORS` 校验(取自 spec 而非手抄一份);行结果跑在一个
41+
复刻 `memory-matcher.ts` 各 arm 的求值面上 —— `a.b` 只命中字面行、`50% (+)` 命中它该
42+
命中的那一行且**恰好**只有那一行(修复前分别是多一行和空集);同一个 filter 再送进
43+
`compileScopedFilterToSql` 确认它现在编译得过。只断言 filter/SQL 字符串会漏掉「不转义」
44+
这一半,所以两半都断言。
45+
46+
顺带删掉 #5558(PR for #5333)在 `objectql-echo-operator-coverage.test.ts` 的替身引擎里
47+
留下的那处 `$regex``$contains` 翻译:它存在的理由就是本单,现在没有了。那也是本修复
48+
最直接的反向证据 —— 把 `case 'contains'` 退回 `$regex`,该文件的 `$contains` 行会以
49+
上面第 2 条的 fail-closed 报错红掉。
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
---
2+
"@objectstack/service-analytics": patch
3+
---
4+
5+
fix(service-analytics): `/analytics/sql` 回显补上 `$startsWith` / `$endsWith` 谓词(#5333)
6+
7+
`ObjectQLStrategy.generateSql` 是同一棵过滤树的**第三个**编译器 —— 输出给浏览器的
8+
展示 SQL。它的 `buildFilterClauseSql` 显式处理 `set`/`notSet`/`in`/`notIn`/
9+
`contains`/`notContains`,其余落到只有六个条目的 `SCALAR_SQL_OPS` 查表;
10+
`startsWith` / `endsWith` 两处都不在,于是走到 `return null`,而**这棵树的每个编译器
11+
都把 `null` 读成「本节点没有约束」**。结果:
12+
13+
| `where` | 实际执行(`NativeSQLStrategy`) | 修复前的回显 | 修复后的回显 |
14+
|---|---|---|---|
15+
| `{stage: {$startsWith: 'w'}}` | `WHERE stage LIKE $1` / `['w%']` | **没有 WHERE**,`params` 为空 | `WHERE stage LIKE $1` / `['w%']` |
16+
| `{stage: {$endsWith: 'n'}}` | `WHERE stage LIKE $1` / `['%n']` | **没有 WHERE**,`params` 为空 | `WHERE stage LIKE $1` / `['%n']` |
17+
| `{stage: {$contains: 'w'}}` | `WHERE stage LIKE $1` | `WHERE stage LIKE $1`(本来就对) | 不变 |
18+
19+
回显比实际执行的查询**更宽**。这个字符串存在的唯一理由就是复现执行 —— 文件自己在渲染
20+
块顶上写着 “a rendering that contradicts execution is worse than no rendering” ——
21+
所以一个带着「为什么这张图少了几行」来看回显的作者,拿到的是一条**没有该筛选条件**
22+
语句:跑一遍返回更多行,于是结论是「筛选器没生效」,而实际执行是生效的。与
23+
#3601 / #3602 / #3650 同一类「回显与执行不一致」,只是这次是从**算子表**这一侧到达的。
24+
25+
不涉及越权或错行:该字符串从不执行(`execute()` 的 echo 会丢弃 `params`),损害限于
26+
可调试性。
27+
28+
**两处修改:**
29+
30+
1. **LIKE 家族收进一张表。** 新增 `LIKE_SQL_OPS`,四个算子(`contains` /
31+
`notContains` / `startsWith` / `endsWith`)的 SQL 拼写与 pattern 并排放在一起,
32+
`NativeSQLStrategy.buildFilterClause``opMap` / `likePattern` 逐条对应 ——
33+
回显描述的正是那个编译器产出的语句,两张表并列摆着,漂移才看得见。
34+
`contains` / `notContains` 的产物一字未变。
35+
36+
2. **「渲染不了就静默丢」的出口改为 THROW。** `return null` 在这里与「无约束」同形,
37+
所以下一个新增算子会以同样的方式再丢一次。之所以**可以**抛错:上游算子词汇表是
38+
**封闭**的 —— `filter-normalizer.ts``fieldLeaves` 是叶节点的唯一生产者,它对
39+
`MONGO_TO_CUBE_OP` 之外的算子在建叶之前就以 `INVALID_FILTER` / 400 拒绝。因此任何
40+
调用方写出的过滤器都到不了这个出口;真到了,只能意味着 normalizer 的表新增了这里
41+
没有分支的算子,那是我们自己两张表漂移,而对此**唯一不能给的答案就是悄悄放宽作者的
42+
查询**。与 `convertFilter``default:` 分支在 #4128 做出的是同一个选择;刻意****
43+
`invalidFilterError` 的 400 信封 —— 这不是调用方形状的错误。
44+
45+
**该 throw 出口今天从公共入口不可达,这一点是测过的、也是刻意报告的**:把它改回
46+
`return null`(保留第 1 项修改)只会让它自己那一条断言变红,枚举断言和回显对照表
47+
全部保持绿色。它是一个漂移探针,不是行为修复 —— 行为修复是第 1 项。
48+
49+
新增 `objectql-echo-operator-coverage.test.ts`:issue 那张对照表按**行结果**钉住
50+
(回显语句在同一份 fixture 上真的被执行,行 id 与查询实际返回的行 id 比对 —— 丢掉的
51+
谓词藏不住,它返回的正是筛选器排除掉的行),再按 `filter.zod.ts`
52+
`FILTER_OPERATORS` 枚举全部 15 个可编写算子,逐个断言回显渲染出谓词、且
53+
placeholder 与 `params` 对齐。只断言 SQL 字符串会放过下一个未映射的算子 —— #4128
54+
`$between` 就藏在 `$startsWith` 后面。
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: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
---
2+
"@objectstack/spec": minor
3+
"@objectstack/plugin-approvals": minor
4+
"@objectstack/lint": minor
5+
---
6+
7+
fix(approvals): the ADR-0044 revise window is a service-owned node type, not a bare `wait` (#3823)
8+
9+
#3801 gated `POST /api/v1/automation/:name/runs/:runId/resume` on the **node type**
10+
that produced the suspension: an `approval` pause declares
11+
`resumeAuthority: 'service'`, so it continues only through `ApprovalService`.
12+
ADR-0044's **revise window** was the same trust boundary in a shape that key
13+
could not see. Send-back parked the run on an ordinary `wait` node the flow
14+
author placed — correctly `resumeAuthority: 'any'`, because a signal wait is
15+
*meant* to be resumable by an external producer — and `ApprovalService.resubmit`
16+
was the only thing that checked anything about continuing it.
17+
18+
Demonstrated (not reasoned) against the real engine: a raw `resume(runId)` with
19+
an **empty body**, from any caller, walked the `resubmit` back-edge into the
20+
approval node and opened round N+1 with **no submitter check and no `resubmit`
21+
audit row** (`['submit','revise']` — no third row, ever). Worse, when another
22+
request was already pending on the record — the exact case `resubmit` refuses
23+
with `DUPLICATE_REQUEST` *specifically to keep the run alive* — the raw resume
24+
went around that guard: the approval node's re-entry failed **after** the engine
25+
consumed the suspension, and the run was **permanently destroyed** with its
26+
round-N request stuck `returned` and no resubmit able to reach it.
27+
28+
The revise pause is therefore its own node type:
29+
30+
- **`approval_revise`** (`APPROVAL_REVISE_NODE_TYPE`), registered by
31+
`@objectstack/plugin-approvals` alongside the `approval` node, declaring
32+
`resumeAuthority: 'service'`. It stays a first-class box on the canvas, in the
33+
run log and in the suspended-run store — only the *reuse* of `wait` was wrong.
34+
It takes **no config**: the window ends on the submitter's explicit resubmit,
35+
never on a signal or timer. The `resumeAuthority` gate itself is unchanged.
36+
- `sendBack` refuses a `revise` edge whose target is not an `approval_revise`
37+
node, **before any mutation** (like the existing missing-`revise`-edge check),
38+
so no run can be parked in a window something else can advance.
39+
- New gating lint `flow-approval-revise-target-not-service-owned`
40+
(severity `error`, on `os build` / `os validate` / `os lint` and the runtime
41+
metadata publish gate) rejects the old shape at authoring time.
42+
43+
**Upgrading a flow authored against the original ADR-0044 D3.** One token:
44+
45+
- **FROM:** `{ id: 'wait_revision', type: 'wait', waitEventConfig: { eventType: 'signal', … } }`
46+
- **TO:** `{ id: 'wait_revision', type: 'approval_revise' }` — drop
47+
`waitEventConfig` / any `config`; the window has no event to wait on.
48+
49+
Until you do, such a flow keeps registering and running and its approvals stay
50+
decidable (`approve` / `reject` / `recall` / `reassign` are untouched), but
51+
**send-back is refused** with a message naming the node and this fix, and
52+
re-publishing it reports the lint error. A run *already parked* in a legacy
53+
revise window keeps its recorded node type (a republish never re-types a live
54+
pause) and is drained by `resubmit` or `recall` as usual.
55+
56+
ADR-0044's 2026-07-28 amendment records the reversal of its D3 and of its
57+
`Alternatives` rejection of a service-owned revise pause, with the evidence
58+
above; the implementation section there records what shipped, why the approval
59+
node does not re-suspend itself instead, and why no ADR-0087 conversion was
60+
added for the old shape.
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.

0 commit comments

Comments
 (0)