Skip to content

Commit 1d6bf8c

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

289 files changed

Lines changed: 15258 additions & 1268 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: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
---
2+
"@objectstack/service-analytics": patch
3+
---
4+
5+
fix(service-analytics): 过滤值不再被降级成字符串 —— `{code: {$eq: '007'}}` / `'null'` / `'true'` 按作者写的字面值绑定 (#5526)
6+
7+
analytics 的 `filter-normalizer` 内部把每个比较数(comparand)压成 `values: string[]`
8+
再由消费方****回类型:出口是 `stringifyForCube`,入口是 `recoverNumber`
9+
`coerceFilterValueForSql` / `coerceFilterValueForObjectQL`。字母表是"全体字符串"、
10+
解码规则是"这串看起来像不像数字/布尔/null"的编码没有任何转义机制,于是作者写的字符串
11+
和编码器为其他类型写下的 token 撞车。`{code: {$eq: v}}``main` 上实测:
12+
13+
| 作者的 `v` | SQL 绑定 | 引擎绑定 |
14+
|---|---|---|
15+
| `'007'` | `7`(#5528 已修) | `7`(#5528 已修) |
16+
| `'1.50'` | `1.5`(#5528 已修) | `1.5`(#5528 已修) |
17+
| `'null'` | 真 NULL |`null` |
18+
| `'true'` | `1` | `true` |
19+
20+
每一行都是一个缺陷:存着作者那种写法的 TEXT 列不再匹配。`'007'` 在 SQLite 上是
21+
整数与 TEXT 列的跨类型比较、恒不相等,在 Postgres 上 `text = integer` 直接报类型错;
22+
`'null'` 那一行比"空"更糟 —— 与真 NULL 的比较对任何行都是 UNKNOWN,图表永远画不出东西。
23+
零填充串、当枚举码用的 `'true'`/`'false'`、当字面标签用的 `'null'` 都是真实业务形状
24+
(订单号、SKU、邮编、国际长途区号)。
25+
26+
**修法**:`NormalizedFilterNode` 的 leaf `values``string[]` 改为 `unknown[]`,
27+
作者写的值原样穿过整棵树,不再有任何东西去解码它。仅在边界真正要求时才转换:
28+
29+
- `toSqlBindValue`(唯一留下的转换,且是**单向**的:值 → 它的 SQL 绑定形态,不是解码器)
30+
——只处理驱动绑不了的 JS 类型:`boolean``1`/`0`(better-sqlite3 拒绝 JS 布尔)、
31+
`Date` → ISO 文本、其他对象 → JSON 文本。它不检查任何字符串。
32+
- LIKE 族的比较数被 `filter.zod.ts` 声明为 `z.string()`,所以在发射点字符串化 ——
33+
`driver-sql``applyLike` 同一个 `String(value)`,两个面上 `$contains` 仍是一件事。
34+
35+
ObjectQL 引擎路径现在不需要任何转换:引擎按**存储**的运行时类型比较,而它拿到的就是
36+
作者写的值。`stringifyForCube` / `recoverNumber` / `coerceFilterValueForSql` /
37+
`coerceFilterValueForObjectQL` 一并删除。
38+
39+
两处读法作为直接后果改变了,方向都是 fail-closed:
40+
41+
- `{name: {$contains: null}}` 原先编译成 `LIKE '%%'` —— 匹配**每一个**非 NULL 行,
42+
因为 `stringifyForCube(null)``''`;现在是 `LIKE '%null%'`,与 `driver-sql`
43+
一直以来的编译结果一致。
44+
- `{amount: {$gt: null}}` 原先编译成 `amount > ''`(一次针对空字符串的真实比较);
45+
现在绑定 NULL,谓词为 UNKNOWN、图表画不出行 —— 无序比较数的诚实答案,也是
46+
`driver-memory` / `formula` 给出的答案。(#5332 明确指出这个比较数位置没有任何裁决
47+
覆盖、`''` 只是占位符;删掉编码器就按构造把它定了。)
48+
49+
`timeDimensions[].dateRange` 的两个边界现在按 spec 声明的类型(`string[]`)原样传递:
50+
原先它们也过 `coerceFilterValueForObjectQL`,其文档宣称"epoch-ms 边界会还原成数字"——
51+
那是消费方在宽容地兜一个契约并未声明的形状,和把 `'007'` 读成 `7` 是同一个猜测
52+
(Prime Directive #12:epoch-ms 窗口要么在生产者、要么在 spec 里声明,不在这里猜)。
53+
54+
`{stage: null}` / `{$eq: null}` / `{$ne: null}` / `{$null:}` / `{$exists:}` 的空值
55+
谓词语义(#5332 / #5525)不变:真 `null` 比较数编译成 `notSet` / `set`,从不进入
56+
`values`#5567 的 LIKE 转义契约不变。

.changeset/config.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
"@objectstack/driver-sql",
3535
"@objectstack/driver-mongodb",
3636
"@objectstack/driver-sqlite-wasm",
37+
"@objectstack/driver-turso",
3738
"@objectstack/plugin-approvals",
3839
"@objectstack/plugin-audit",
3940
"@objectstack/plugin-auth",
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
---
2+
"@objectstack/service-automation": patch
3+
"@objectstack/core": patch
4+
---
5+
6+
fix(service-automation): connector 物化失败的软路径改用结构化 `meta`;顺带修好 `ObjectLogger.error` 丢弃契约第三参的缺陷 (#5575)
7+
8+
## service-automation:`fail(msg, cause)`
9+
10+
`reconcileDeclaredConnectors` 的报错器有两条路径(ADR-0097):冷启动 `throw`(fatal),
11+
`metadata:reloaded` 之后 —— Studio publish、`os dev` 重编译 —— 记日志并让旧 connector
12+
继续服务(soft)。其中两个调用点把**外来**`err.message` 插进那条日志 message:
13+
`resolveInstanceAuth` 失败处,以及 provider factory 抛错处。这两个 message 都不是我们
14+
自己的:credential resolver 由宿主提供
15+
(`AutomationServicePluginOptions.credentialResolver`),provider factory 更是 ADR-0097
16+
明确鼓励第三方去写的代码 —— 第一个用严格 Zod schema 校验 `providerConfig` 的 factory
17+
抛出的就是 `ZodError`,它的 `.message` 是 issue 数组的多行 JSON dump,第一行是一个 `[`
18+
19+
`ObjectLogger` 每次调用只写一条 `<ts> <LEVEL> <msg>` 记录,带换行的 message 会溢出到
20+
不带等级头的后续物理行,于是运行时 stderr 的每一个按行工作的消费者 —— 文件 sink、
21+
`docker logs`/journald 送进日志采集、一次 `grep ERROR` —— 都会把那些续行读成无法归属的
22+
垃圾记录:一条诊断散成 N 个碎片。与 #5048 在 flow 绑定接缝上是同一类,也是同一条 #4632
23+
原则:被搅烂的诊断比没有诊断更贵。
24+
25+
改法与 PR #5572 同源:`fail(msg, cause?)` —— message 是不含换行的自足句子,cause 按路径
26+
分别渲染。soft 路径把 cause 交给 logger 的**结构化 meta**(`issues[]` / `error`);fatal
27+
路径把 cause 文本接在抛出的 message 后面(`… cause: <text>`),因为 throw 不是日志记录,
28+
内核失败通道原样打印,多行 ZodError dump 在终端里本来就好读 —— 同一个 cause,两种受众,
29+
刻意不共用一种形状。`#5048` 引入的内部模块随之从 `flow-bind-diagnostics.ts` 更名为
30+
`thrown-cause-diagnostics.ts`(`describeThrownForLog`),因为它从来不是 flow 专属的:
31+
主题是日志管线,不是 metadata 类型。被拒键名仍放在 `unrecognized` 而不是 Zod 原本的
32+
`keys`(`ObjectLogger` 的脱敏表按子串匹配,`keys``key`)。
33+
34+
**一处订正**:#5575 的 issue 正文把此处的危害归给了 `serve` 的启动诊断缓冲
35+
(`BootLogCapture`)。那个缓冲看不到这条路径 —— `ObjectLogger``warn` 送 stdout(启动
36+
静默窗口只包了 `process.stdout.write`),`error`/`fatal`**stderr**,而且 soft 路径在
37+
`metadata:reloaded` 之后才跑,窗口早已恢复。危害是上面那串按行消费者,以及日志查询根本
38+
无法按字段过滤;机制写进了模块文档,连同 `warn`/`error` 下游不同这件事本身。
39+
40+
## core:`ObjectLogger.error`/`fatal` 兑现契约声明的 `meta`
41+
42+
`Logger` 契约声明 `error(message, error?: Error, meta?)``ObjectLogger` 按形状分派,
43+
所以 meta 也允许出现在 `error` 位 —— 这份宽容没问题;**丢掉一个自己声明的参数**有问题:
44+
`error === undefined` 时旧代码走 `write(level, message, errorOrMeta)`,第三个参数从未被
45+
读取。于是每一个按契约书写的 `logger.error(msg, undefined, { … })` 都只输出一条裸 message,
46+
事实全部静默消失 —— `metadata``metadata-protocol``client``core/security` 里约 15 处
47+
调用点今天就是这样(其中 `metadata/src/endpoint-matcher.ts` 送的正是一个 Zod issue 数组)。
48+
契约的另外两个实现(`@objectstack/observability``ConsoleLogger`/`JsonLogger`)都老老实实
49+
用了这个位置,所以是契约对、这一个实现错:declared ≠ enforced。
50+
51+
三种形状现在都被兑现,两个位置同时带值时以更靠后的 `meta` 为准。这一处修好之后,上述
52+
调用点的诊断自动恢复(`client``HTTP request failed` 记录重新带上
53+
`{method, url, status, error}`)。connector 接缝改用契约的第三参而非第二参,是刻意的:
54+
把原始 error 塞进第二位会让每条记录都附带完整堆栈,ZodError 还会附带整段多行 dump ——
55+
正是我们要消灭的无界形状。
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
---
2+
'@objectstack/cli': patch
3+
---
4+
5+
`os dev --fresh` states the isolation it actually delivers (#5594)
6+
7+
The `--fresh` block promised its tempdir "owns ALL persistent state for this
8+
run". After #4968 that is true of everything the CLI itself places — the dev
9+
SQLite DB (`OS_HOME``<home>/data/dev.db`, published as `OS_DATABASE_URL`),
10+
the uploads root (published on the settings service's own name
11+
`OS_STORAGE_LOCAL_ROOT`), and any plugin state keyed off `OS_HOME` — but it was
12+
never true of state an **app** reaches by a relative path it declares itself.
13+
Such a path is resolved by its own consumer against the process working
14+
directory, which `--fresh` does not move, so the file lands in the project tree
15+
and is still there after the run exits.
16+
17+
The live specimen is deliberate authoring, not a bug: the showcase's
18+
`showcase-external` datasource declares
19+
`filename: '.objectstack/data/showcase_external.db'` and documents that the path
20+
resolves against the project cwd — so a `--fresh` showcase run leaves that file
21+
(plus `-wal`/`-shm`) behind.
22+
23+
No behaviour changed. The `--fresh` flag help, the source comments, and the
24+
`os dev` flag table in the CLI docs now name the covered surface
25+
(`OS_HOME`-keyed state plus the env channels the CLI publishes) and state
26+
plainly what falls outside it, with a docs note on declaring an absolute path
27+
when a datasource should follow `--fresh`.
28+
29+
Re-anchoring app-declared relative paths on `OS_HOME` is a behaviour change
30+
resting on an open contract question ("relative to cwd" vs "relative to this
31+
run's home") and is deliberately not taken here.
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
---
2+
"@objectstack/driver-turso": minor
3+
"@objectstack/driver-memory": patch
4+
"@objectstack/driver-mongodb": patch
5+
"@objectstack/driver-sql": patch
6+
"@objectstack/driver-sqlite-wasm": patch
7+
---
8+
9+
feat(drivers): `@objectstack/driver-turso` 迁回本仓并公开发布,五个 driver 统一收进 `packages/drivers/` (#4645)
10+
11+
`TursoDriver` 一直以 `extends SqlDriver` 的方式**跨仓库继承**本仓的类,自己却住在闭源的
12+
`objectstack-ai/cloud``publishConfig: restricted`)。而本仓的 runtime 早就把 turso 当一等
13+
公民——`http-dispatcher.ts` 里环境 provisioning 的偏好顺序第一位就是它,`POST /cloud/environments`
14+
`driver` 参数示例是 `memory | turso``objectql/src/engine.ts` 还带着一段 turso 专属的瞬时
15+
`fetch failed` 重试。开源侧的代码路径引用着一个自己仓里既测不到也 grep 不到的 driver,闭源侧则
16+
在每次 pin bump 时追赶父类的重构。维护者裁定把核心迁回本仓、公开 Apache-2.0 发布。
17+
18+
**新包 `@objectstack/driver-turso``packages/drivers/driver-turso`,Apache-2.0,`access: public`**
19+
带着它在 cloud 的全部实现与测试落地:`TursoDriver`(local / replica / remote 三种传输模式)、
20+
`RemoteTransport`(纯 `@libsql/client` 走 HTTP/WebSocket,无原生依赖,可跑 serverless/edge)、
21+
驱动的 spec/Studio 元数据,以及 15 个测试文件 538 条断言——全部 hermetic,默认 CI 下不碰网络、
22+
不要凭据(remote 面走包内的 sqlite stub)。
23+
24+
**留在 cloud(不随迁)**:按租户路由的 `multi-tenant.ts`(云产品差异化能力)及其 schema、
25+
`vector-poc.test.ts`。因此本包的 barrel **不再导出** `createMultiTenantRouter` /
26+
`MultiTenantConfig` / `MultiTenantRouter`,也不导出多租户 schema——它们从来不是这个 driver 的
27+
一部分,只是曾经同包而已。
28+
29+
**目录重组**:五个 `IDataDriver` 实现(`driver-memory` / `driver-mongodb` / `driver-sql` /
30+
`driver-sqlite-wasm` + 迁入的 `driver-turso`)现在都住在 `packages/drivers/`
31+
`knowledge-*``embedder-*` 留在 `packages/plugins/`。四个存量包**内容零改动**,只有
32+
`repository.directory` 随目录更新——包名、入口、导出面、行为全部不变,消费者无需改动任何 import。
33+
34+
这也把 turso 交给了本仓的仓库级守卫:`check:driver-conformance` 从磁盘发现 driver 包,
35+
迁入即入矩阵(5 drivers × 5 case-sets)。它的 temporal 两格是真绿(local 与 remote 双面套件),
36+
filter 组合语义与两个分页 case-set 记为 measured DEBT——remote 传输自带一套 `buildWhereSQL`
37+
`LIMIT`/`OFFSET` 拼装,是独立实现,"继承所以没问题"正是这些共享套件存在来证伪的假设。
38+
补齐工作跟踪在 #5590
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
---
2+
"@objectstack/metadata": patch
3+
---
4+
5+
fix(metadata): an unreadable file is no longer announced as `data: null` (#5228)
6+
7+
`NodeMetadataManager.handleFileEvent()` — the chokidar handler behind
8+
`watch: true` — wrapped its re-read in a `try/catch` that logged
9+
"Failed to load changed file" and returned without announcing. That `catch` was
10+
**unreachable for the failure it was written to catch**. `load()` is
11+
`(await loadDiagnosed(...)).data`, and `loadDiagnosed` (ADR-0110 D3)
12+
deliberately absorbs a loader throw: it records the message in `errors[]` and
13+
answers `{ data: null, degraded: true }`. `FilesystemLoader.load()` does throw
14+
on an unparseable file — the throw simply died one frame below the handler, so
15+
the `catch` never ran and the `logger.error` inside it never printed once.
16+
17+
What went out instead was a watch event carrying `data: null`, which is the wire
18+
shape of "this metadata legitimately holds nothing". A file the loader could not
19+
read and a file the author had emptied reached every subscriber in exactly the
20+
same shape — the miss/outage distinction ADR-0110 D3 exists to preserve, erased
21+
at the one call site that had picked the variant which throws it away.
22+
23+
The handler now reads through `loadDiagnosed` and splits on `degraded`:
24+
25+
- **Degraded** (a loader threw and none answered — an unreadable or unparseable
26+
file): take the road the dead `catch` meant to take. Log `filePath`, the
27+
metadata type and name, and `loadDiagnosed`'s `errors[]`, and announce
28+
nothing. A developer who breaks a metadata file now gets told; before, the
29+
event claimed the definition had been emptied and nothing was logged.
30+
- **Clean miss** (`data: null`, no loader threw — the file is gone or
31+
legitimately empty): unchanged, announced exactly as before.
32+
- **Deleted** events never read, so a deletion can never be degraded and is
33+
always announced.
34+
35+
Cache invalidation is unaffected and deliberately runs **before** the read, so
36+
the read's verdict can never decide whether the caches are dropped. #5218's
37+
contract holds in full: an unreadable file is still a real change to the stored
38+
set (`loadMany` skips it), so `listCache` and the `registry` entry still go, and
39+
the `api` endpoint index still rebuilds — `invalidateListCache` is that index's
40+
first invalidation seam (#5089), so suppressing the announcement costs it
41+
nothing.
42+
43+
No in-repo subscriber loses invalidation or reload correctness: the endpoint
44+
index is covered by the seam above, `ObjectQLPlugin`'s `subscribe('object', …)`
45+
answers events by re-reading (an unreadable file yields nothing to re-read
46+
either way), and the email-template bridge falls through `event.data ?? get(...)`
47+
to the same empty result. One behaviour does change for the dev HMR/SSE stream:
48+
a file left permanently unparseable no longer wakes the Studio, which keeps
49+
showing the last known-good definition until the next event instead of watching
50+
it vanish.
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
---
2+
"@objectstack/lint": patch
3+
---
4+
5+
fix(lint): the flow rule family now descends into `loop` bodies and every other nested region (#5383)
6+
7+
The flow anti-pattern rules read a flow's `nodes` / `edges` **flat off the top
8+
level**, so every rule in the family was blind to anything authored inside an
9+
ADR-0031 container — a `loop` body, a `parallel` branch, a `try_catch`
10+
try/catch. Loop bodies are where a lot of real branching lives (a per-item gate
11+
inside a sweep is the standard shape for a scheduled flow), so this was a large
12+
share of authorable flow metadata that no flow rule inspected.
13+
14+
Measured in a real app: 8 `decision` nodes carried the inert singular
15+
`config.condition` that `flow-inert-node-condition` exists to catch, all 8
16+
inside a `loop` body, and `pnpm lint` reported none of them. The identical key
17+
on a **top-level** decision in the same repo fired immediately — same key, same
18+
node type, only the nesting depth differed. The blind spot also explains its own
19+
survival: the gate visibly worked where it could see, so the top-level copies
20+
got cleaned up while the nested ones read as approved.
21+
22+
Rules now reported at every depth: `flow-inert-node-condition`,
23+
`flow-decision-unconditional-branch`, `flow-branch-label-unmatched`,
24+
`flow-default-edge-with-condition`, `flow-multiple-default-edges`,
25+
`flow-double-brace-interpolation`, `flow-bare-dollar-reference`,
26+
`flow-date-equality-filter`, `flow-phantom-aggregation`,
27+
`flow-error-label-not-fault`, and the `flow-approval-revise-*` family. Note the
28+
severity asymmetry this closes: `flow-default-edge-with-condition` is a
29+
build-stopping `error` that until now could not see a contradiction authored one
30+
level down.
31+
32+
A finding inside a region carries the region scope in its `where`, so the
33+
message still points at exactly one node — `flow 'x' · loop 'sweep' body ·
34+
node 'y' (decision)`, matching the scope vocabulary the engine's registration
35+
pass already uses. Findings on a flow's own graph are unchanged, byte for byte.
36+
37+
Two details worth knowing if you consume these findings:
38+
39+
- Each region is scanned against **its own** `edges`. The branch-routing rules
40+
reason about a node together with its out-edges, and a region is a
41+
self-contained sub-graph, so a nested decision's out-edges live in the
42+
region's own edge list.
43+
- `flow-double-brace-interpolation` / `flow-bare-dollar-reference` scan a node's
44+
config recursively, and a container's config physically contains its
45+
descendants'. A nested hit was therefore already *visible* before this change
46+
— but attributed to the enclosing `loop` rather than the node carrying the
47+
string. Such a finding now names the right node, and is still reported exactly
48+
once.
49+
50+
`flow-runas-unscoped` deliberately keeps looking at top-level nodes only:
51+
widening a build-gating rule is its own change with its own blast radius, and is
52+
tracked separately.

0 commit comments

Comments
 (0)