Skip to content

Commit 6183dee

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-5503-autonumber-readonly-strip
2 parents d7992f1 + cc5b048 commit 6183dee

36 files changed

Lines changed: 2632 additions & 77 deletions
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: 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`(契约优先),那是唯一仍会把措辞交给调用方的路径。

.claude/agents/os-dev.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -271,7 +271,7 @@ binary: zero matches, no signal, and the rule you just wrote becomes
271271
invisible to every agent that greps for it. Run
272272
`node scripts/check-nul-bytes.mjs` before pushing, and when your change so
273273
much as *mentions* control characters, self-scan beyond the gate
274-
(`grep -naP '[\x00-\x08\x0b\x0c\x0e-\x1f]' <files>`) — the gate's blind
274+
(`grep -naP '[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]' <files>`) — the gate's blind
275275
spots are exactly where these bytes hide.
276276

277277
The GitHub body sanitizer is the same discipline's other half: it strips `<`

packages/client/vitest.integration.config.ts

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,24 @@ export default defineConfig({
77
environment: 'node',
88
testTimeout: 30000, // 30 seconds for integration tests
99
hookTimeout: 30000,
10-
// Run integration tests sequentially to avoid race conditions
1110
pool: 'forks',
12-
poolOptions: {
13-
forks: {
14-
singleFork: true
15-
}
16-
}
11+
// Run integration test files one at a time: they all drive the SAME live
12+
// server and the same test data, so running files in parallel races.
13+
//
14+
// Vitest 4 removed `test.poolOptions` ("Pool Rework" in the migration
15+
// guide) — the `poolOptions.forks.singleFork: true` that used to live here
16+
// was never read, it only produced a DEPRECATED warning, so this suite has
17+
// been running with the default parallelism all along (#5564).
18+
//
19+
// `fileParallelism: false` is the top-level option that actually enforces
20+
// it: per the docs it "will override `maxWorkers` option to 1", which is
21+
// the parallelism half of what the guide maps `singleFork` to.
22+
//
23+
// The guide's other half — `isolate: false` — is deliberately NOT carried
24+
// over: it was equally inert here, nothing in this suite depends on
25+
// sharing a module registry across files, and turning it off would let
26+
// module state leak between files, which is the very class of cross-file
27+
// interference this declaration exists to prevent.
28+
fileParallelism: false
1729
}
1830
});

packages/metadata-core/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@
2929
"clean": "rm -rf dist",
3030
"test": "vitest run",
3131
"test:watch": "vitest",
32-
"typecheck": "tsc --noEmit"
32+
"typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.test.json"
3333
},
3434
"keywords": [
3535
"objectstack",
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
// The TEST-layer type-check program (#5476, the mechanism #5286/PR #5478 set for
2+
// `packages/spec` and PR #5546 carried to `packages/client`). `tsconfig.json`
3+
// beside this one stays as it is: it is the BUILD config, and `package.json`'s
4+
// `typecheck` script NAMES this sibling (`tsc --noEmit -p tsconfig.test.json`),
5+
// because a config no script invokes is exactly the phantom this change is about.
6+
//
7+
// THIS PACKAGE'S HOLE WAS A DIFFERENT SHAPE from spec's and client's, and that
8+
// is what dictates the two options below. Neither of those two configs excluded
9+
// `test/**` — they excluded `**/*.test.ts`, so the repair was to put an excluded
10+
// region back. Here nothing was excluded at all: `include` is `["src/**/*"]` and
11+
// the six files under `test/` simply live outside that root, so no `exclude`
12+
// entry names them and TESTS_COVERED (which counts only under the include roots)
13+
// never saw them either — this package's testFiles count was 0. The two test
14+
// files that live under `src/` (`protocol-handshake.test.ts`,
15+
// `objects/sys-view-definition.object.test.ts`) were compiled all along; the
16+
// sibling `test/` tree was not.
17+
//
18+
// What differs from the build config, and what deliberately does NOT:
19+
// - `rootDir` widens from `src` to the package root. It steers emit layout
20+
// only, and this program emits nothing (`noEmit`), but inherited as `src` it
21+
// reports TS6059 ("not under rootDir") for all six `test/**` files — the
22+
// check being misconfigured, not the tests being wrong. Widening it in the
23+
// BUILD config instead is not an option: `tsc` there emits (`dev`:
24+
// `tsc --watch`, `outDir: dist`), so a package-root `rootDir` would relocate
25+
// `dist/index.js` to `dist/src/index.js` — breaking `main`/`exports` — and
26+
// start writing `dist/test/**/*.test.js`, which ci.yml gates against ("No
27+
// compiled test files in any dist"). Emit constraints belong to the build
28+
// config; this one has none.
29+
// - MODULE SEMANTICS ARE UNTOUCHED, unlike spec's and client's siblings. Those
30+
// packages have no `"type": "module"`, so the build config's NodeNext
31+
// compiled their ESM tests as CJS and reported errors about the CHECK
32+
// (TS2835, TS1470, TS2550); switching to `esnext`/`bundler` was fidelity to
33+
// how vitest executes them. `@objectstack/metadata-core` IS `"type":
34+
// "module"`, so NodeNext already reads these files as ESM — and it is the
35+
// STRICTER of the two, since it holds the `.js` import extensions this
36+
// package must ship (`bundler` resolution would let a missing extension
37+
// compile here and fail at runtime under Node). Nothing to fix, so nothing
38+
// is changed.
39+
// - STRICTNESS IS UNTOUCHED. `strict`, `noUnusedLocals`, `noUnusedParameters`,
40+
// `noImplicitReturns` and the rest are inherited from the root config.
41+
// Nothing here may loosen a type rule; if a test does not compile, that is
42+
// the finding.
43+
//
44+
// There is NO `test-typecheck-debt.json` beside this config, on purpose. The
45+
// whole test layer compiles at ZERO errors under it, so the per-file EXACT
46+
// shrink-only ledger (`scripts/check-test-typecheck.mts`, which spec and client
47+
// wire because they carry 691 and 6 residual errors) would hold nothing while
48+
// costing this package a `tsx` dependency and two more scripts. A bare
49+
// `tsc --noEmit -p tsconfig.test.json` is the strictly stronger gate at zero
50+
// residue: ANY error here is red immediately, with no ledger to be added to.
51+
// If this package ever acquires residue that cannot be fixed in its own PR,
52+
// that is the moment to wire the shared script — not before.
53+
{
54+
"extends": "./tsconfig.json",
55+
"compilerOptions": {
56+
"noEmit": true,
57+
"rootDir": "."
58+
},
59+
"include": ["src/**/*", "test/**/*"],
60+
"exclude": ["node_modules", "dist"]
61+
}

packages/metadata-protocol/src/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
22

33
export { ObjectStackProtocolImplementation, ConcurrentUpdateError, normalizeViewMetadata, graftNormalizedOperators, stripReadDecorations } from './protocol.js';
4+
// [#5138] The 404 envelope every single-record path answers, exported so the
5+
// ObjectQL FALLBACK in `@objectstack/runtime`'s `callData` builds the SAME one
6+
// instead of minting a second not-found shape. See `recordNotFoundError`.
7+
export { recordNotFoundError } from './protocol.js';
48
export { createMetadataProtocolPlugin, assembleMetadataProtocol } from './plugin.js';
59
export type { MetadataProtocolPluginOptions } from './plugin.js';
610
export type { UninstallCleanup, UninstallCleanupOutcome } from './protocol.js';

0 commit comments

Comments
 (0)