Skip to content

Commit dee19aa

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-5273-hook-input-contract-prose
2 parents 7b64f96 + ec6fad8 commit dee19aa

33 files changed

Lines changed: 3543 additions & 127 deletions
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
---
2+
"@objectstack/client": major
3+
"@objectstack/cli": patch
4+
---
5+
6+
fix(client)!: `DeleteDataResult` declares the schema it names — `success`, not `deleted` (#5638)
7+
8+
`DeleteDataResult` — the return type of `client.data.delete()` and of the
9+
project-scoped `client.project(id).data.delete()` — carried the comment
10+
`Spec: DeleteDataResponseSchema` above a declaration that contradicted it:
11+
12+
```ts
13+
/** Spec: DeleteDataResponseSchema */
14+
export interface DeleteDataResult {
15+
object: string;
16+
id: string;
17+
deleted: boolean; // ← the schema declares `success`
18+
}
19+
```
20+
21+
`DeleteDataResponseSchema` (`packages/spec/src/api/protocol.zod.ts`) declares
22+
`{ object, id, success }`. `deleted` has never been declared by any schema, and
23+
no server path has ever returned it on `/data/:object/:id`.
24+
25+
**The old key was never readable at runtime — this rename reveals a defect, it
26+
does not break working code.** Both `delete` surfaces are pure `unwrapResponse`
27+
/ `_unwrap` passthroughs: the SDK returns the server's body untouched, so this
28+
interface is a *claim* about the wire, never a rewrite of it. The claim was
29+
false in the one direction that matters — the compiler endorsed the wrong
30+
spelling:
31+
32+
```ts
33+
const r = await client.data.delete('task', id);
34+
if (r.deleted) { … } // compiled; `undefined` at runtime; branch never taken
35+
if (r.success) { … } // rejected by the compiler; correct on the wire
36+
```
37+
38+
## What to change
39+
40+
`r.deleted``r.success`. That is the whole migration. Nothing about the
41+
request, the route, the status codes or the error shapes changes, and no server
42+
needs upgrading: the value you are now allowed to read is the one that was
43+
already arriving.
44+
45+
**Do not write `r.success ?? r.deleted`.** There is one producer shape, and a
46+
consumer that accepts two spellings is the shape contract-first exists to
47+
prevent — the same ruling #5581 applied on the producer side. No deprecated
48+
`deleted?: boolean` transition key ships for the same reason; a transition
49+
period is for keys that *worked*, and this one never did.
50+
51+
## Why the type was wrong on every deployment, not just some
52+
53+
The protocol path (`deleteData`) has always answered `success`. #5581 / PR
54+
#5641 brought the ObjectQL fallback — the path a slim assembly without
55+
`MetadataPlugin` takes — to the same shape. So before that fix the declaration
56+
was wrong on ordinary deployments and accidentally right on slim ones; after
57+
it, both paths answer `{ object, id, success }` and the declaration was simply
58+
wrong everywhere. The consumer-side correction had to follow the producer, not
59+
lead it.
60+
61+
## `os data delete` was reading the phantom key too
62+
63+
`packages/cli/src/commands/data/delete.ts` built its `--format json` / `--format
64+
yaml` payload with `deleted: result.deleted`. That evaluated to `undefined`, and
65+
`JSON.stringify` drops undefined values — so the `deleted` key the command has
66+
always declared **never appeared in a single run**. It now carries
67+
`result.success`, the server's own verdict.
68+
69+
Observable change: `os data delete --format json` gains `deleted: true` (YAML
70+
likewise) on a successful delete. The key name stays `deleted` deliberately —
71+
it is the CLI's output key, not the protocol's, and the payload's top-level
72+
`success` already means something different (the CLI envelope's "the command
73+
completed"). Conflating the two is the hazard #5641 called out when it noted
74+
that `body.success` and `body.data.success` are different facts. Scripts
75+
reading `.deleted` from this command were reading `undefined` before and get a
76+
boolean now; nothing that worked stops working.
77+
78+
## Downstream
79+
80+
`objectui`'s `ObjectStackDataSource.delete()` is a live victim of the old
81+
declaration — it guards `emitMutation` on `result.deleted`, so the delete
82+
mutation event has never fired against a real server and the method returns
83+
`undefined` where it declares `boolean`. Its own suite stayed green because the
84+
fixture mocks `{ deleted: true }`, a body no server produces. Filed as
85+
objectstack-ai/objectui#3412, which is blocked on this package publishing —
86+
its fix is a type unblock, not a behaviour change, since `success` is already
87+
what arrives.
88+
89+
## Pins
90+
91+
`packages/client/src/data-delete-result-shape.test.ts` asserts mutual
92+
assignability between `DeleteDataResult` and the spec's `DeleteDataResponse`,
93+
so a rename on either side (or a re-added optional `deleted`) fails
94+
`check:test-typecheck`. `client.hono.test.ts` gains the delete case this live
95+
server suite never had: a real DELETE over HTTP whose body is read as
96+
`deleted.success` and whose key set is asserted literally — `z.object` strips
97+
unknown keys, so a passing parse alone cannot prove no stray `deleted` rode
98+
along.
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
---
2+
"@objectstack/service-automation": patch
3+
---
4+
5+
fix(service-automation): connector 降级路径的两条日志改用结构化 `meta`,message 保持单行 (#5636)
6+
7+
## 接缝
8+
9+
`degradeConnectorInstance`(#3017 的降级/重试路径)有两条记录报告的是**外来**失败,却把
10+
它插进了日志 message —— 与 #5048(flow 绑定)、#5575(`reconcileDeclaredConnectors`
11+
`fail()`)同一类,是那两单范围之外的第三个接缝:
12+
13+
- **husk 注册失败**(`warn`):`err` 来自 `engine.registerDegradedConnector`
14+
`ConnectorSchema.parse`,catch 自己的注释就写着「the entry's def no longer parses」,
15+
也就是说这里预期接到的正是 `ZodError` —— 它的 `.message` 是 issue 数组的多行 JSON
16+
dump,第一行只有一个 `[`
17+
- **降级公告**(`error`):文本是 `ConnectorUpstreamUnavailableError.message`,由第三方
18+
provider factory 构造(ADR-0097 明确鼓励第三方去写)。spec 只定义错误类、不约束文本,
19+
所以上游 SDK 的多行失败会原样落在这里。
20+
21+
## 危害:这条 `warn` 的下游与 #5575`error` 不同(实测)
22+
23+
`ObjectLogger``warn` 送 stdout、`error`/`fatal` 送 stderr,而 `serve` 的启动静默窗口
24+
只包了 `process.stdout.write`#5575 的接缝全是 `error`,所以那一单的结论是「启动缓冲根本
25+
看不到」;这一条不同,而且差别是**测出来**的,不是推的:
26+
27+
- 它是 `warn` → stdout,缓冲**确实**看得到;
28+
- 它在**冷启动**就会跑 —— `materializeDeclaredConnectors(ctx, { fatal: true })` 遇到上游
29+
不可达是降级、不是抛错 —— 而窗口此时正开着(`serve` 在 config 加载前接管 stdout,直到
30+
banner 打印才恢复);
31+
- `BootLogCapture.offer()` 只在 `classifyBootLogLine` 能在该物理行上找到 `<ts> <LEVEL>`
32+
头时才保留它,所以插值 dump 的每一条续行是被**直接丢弃**,不只是难解析。
33+
34+
对一份 13 行的插值 ZodError 实测:写出 13 行物理行,缓冲保留 **1** 行(那条止于 Zod `[`
35+
的头行)、丢弃 **12** 行 —— 唯一被留下的那行不含任何事实。这正是 cloud#971 的原始形态。
36+
`error` 那一条走 stderr,不经缓冲,危害是 #5575 那一串按行消费者(文件 sink、
37+
`docker logs`/journald 送采集、`grep ERROR`):一条诊断散成 N 个无法归属的碎片。
38+
39+
## 改法
40+
41+
两条都复用同包 `thrown-cause-diagnostics.ts``describeThrownForLog`(#5572/#5575 落地):
42+
message 是不含换行的自足句子,cause 走 logger 的结构化 meta。位置按 `Logger` 契约区分,
43+
并且是核对源码后确认的而非照抄:`warn(message, meta?)` 没有 `Error` 位,cause 就在**第二**
44+
参;`error(message, error?, meta?)` 的 cause 在**第三**参(第二参塞原始 error 会让每次重试
45+
的记录都附带完整堆栈)。
46+
47+
## 刻意没有改的一件事
48+
49+
`degradedReason` —— `GET /connectors` 展示的、以及 `connector_action` 被拒时引用的那段文本
50+
—— 仍然逐字保留 provider 自己的 message,包含换行。它是人透过 JSON 读的字段,不经按行切分
51+
的消费者;重塑它属于另一次契约变更。因此调用点同时传 `reason`(那段文本)与 `cause`(抛出值
52+
本身):前者喂 husk 与重试簿记,后者只喂日志记录。测试双向钉住了这个分离。
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
---
2+
"@objectstack/cli": patch
3+
---
4+
5+
fix(cli): `os doctor` no longer treats a broken `@objectstack/cloud-connection` install as "not installed" (#5644)
6+
7+
`readInstalledPackageEntries()` reached the installed-package ledger through a
8+
dynamic `import('@objectstack/cloud-connection')` whose `catch` meant "the
9+
optional package is not installed". That is right for one of the two things it
10+
caught:
11+
12+
- **The specifier does not resolve** — the optional package really is not
13+
there. Silence is correct and unchanged: `os doctor` must run to completion in
14+
a checkout that never had it.
15+
- **The package is installed and will not load** — a pruned or unbuilt `dist/`,
16+
an interrupted install, an artefact that throws while it evaluates, a
17+
transitive dependency missing under it. It threw too, so it was answered with
18+
the same silence.
19+
20+
The ADR-0120 D5e unique-scope advisory then saw "no installed packages", found
21+
nothing to report, and the run printed:
22+
23+
```
24+
✓ Unique scope No unconfirmed installation-wide uniques for this 'isolated' environment
25+
```
26+
27+
Measured: with the package present-but-unloadable and a ledger declaring an
28+
installation-wide `unique`, that line was printed and the finding appeared
29+
nowhere, `--verbose` included. It is the same false PASS #5412 removed at the
30+
`readdir` boundary and #5413 at the entry boundary, one boundary further up —
31+
and `os serve`, loading the same package in the same directory, has always named
32+
the failure out loud.
33+
34+
The two states are now separated by **resolution**, not by the `import()` having
35+
thrown (`isModuleNotFoundError()` first — an error that is not a module-not-found
36+
error came from the package itself, so it is present by definition; then
37+
`import.meta.resolve()`, which answers "is the package there" without stating its
38+
entry file, unlike `createRequire().resolve()`). Only the genuinely-absent half
39+
is silent. The other prints an ordinary `HealthCheckResult` through the same
40+
renderer every other check uses:
41+
42+
```
43+
⚠ Unique scope Could not load the installed-package ledger reader (installed packages
44+
NOT checked for installation-wide uniques) — Cannot find module …
45+
```
46+
47+
**Warning, not error**, and the exit code is unchanged, matching its two
48+
siblings: the environment still runs; what broke is doctor's ability to see part
49+
of it. The cause is quoted from the thrower, and `--verbose` expands it together
50+
with the remedy — reinstall, or build the package in a monorepo checkout.
51+
52+
The row is **not** conditional on `.objectstack/installed-packages/` existing.
53+
Doctor cannot honestly say a ledger is absent when the constant naming the
54+
ledger's location is an export of the package that would not load.
55+
56+
One consequence worth stating: in a monorepo checkout where
57+
`packages/cloud-connection` has not been built, `os doctor` under the `isolated`
58+
posture now prints this warning instead of a clean bill. That state is exactly
59+
what sent #5612 chasing a report face that had never regressed.
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
---
2+
"@objectstack/lint": patch
3+
---
4+
5+
fix(lint): report a `config.timeRelative` descriptor the sweep will refuse, at authoring time (#5496)
6+
7+
A flow start node declaring `config.timeRelative` got **zero** authoring-time
8+
diagnostics when its descriptor could not parse. The two rules that look at the
9+
slot each looked at something else: `lint-flow-patterns` decides "this is a
10+
time-relative flow" from `timeRelative != null` alone (never the shape), and
11+
`validate-flow-trigger-readiness`'s existing check reads only
12+
`timeRelative.object`, to compare it against the stack's objects. So
13+
14+
```ts
15+
config: { timeRelative: { object: 'task', field: 'due_at', offsetDays: -1 } }
16+
```
17+
18+
— three separate schema violations: `dateField` missing, `offsetDays` declared
19+
as an int **array** and written as a scalar, and `field` an unrecognized key —
20+
passed `os validate` silently. `TimeRelativeTriggerSchema` does reject it, but
21+
the only place that schema ran was **bind time**, inside
22+
`TimeRelativeTriggerPlugin.start()`, which warns and returns: the sweep is never
23+
installed, the flow reports itself armed, and the author's sole feedback is one
24+
line in a server log. For an AI author that line is outside the feedback loop
25+
entirely; `os validate` is what it reads.
26+
27+
**New rule — `flow-time-relative-descriptor-invalid` (warning).** A start node
28+
whose `config.timeRelative` is present runs that same schema at authoring time,
29+
and a failure is reported naming `config.timeRelative` with the schema's own
30+
issue list forwarded — so the diagnostic carries the missing key, the wrong type,
31+
and, for an unrecognized key, the "did you mean" the schema already computes
32+
(`field``dateField`) plus its wrong-layer guidance (a `schedule` written
33+
*inside* the descriptor is told it belongs beside it). The list is rendered
34+
exactly as the bind-time warning renders it, so the two channels tell one story.
35+
36+
Nothing is shifted except **when** the schema runs. No shape knowledge is
37+
re-implemented in the rule and no consumer-side tolerance is added: the verdict
38+
and every word of its wording remain `TimeRelativeTriggerSchema`'s, so the rule
39+
tracks the descriptor's contract as it evolves instead of drifting from a second
40+
copy of it.
41+
42+
The rule and the existing object-name check decide different facts and cannot
43+
report the same one twice — only the stack knows whether an object name exists,
44+
and only the schema knows the descriptor's shape. A descriptor wrong in both ways
45+
gets both findings, at their own paths. Canonical descriptors are unaffected:
46+
every one shipped in the repo (the showcase `Task Due Reminder`, the
47+
`content/docs` examples) parses, so this adds no diagnostic to existing apps.
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
---
2+
"@objectstack/lint": patch
3+
---
4+
5+
feat(lint): warn when a `multi: true` delete/update is bounded by nothing — the declared whole-object write (#5482)
6+
7+
A `delete_record` / `update_record` node that declares `multi: true` with no
8+
`filter` (or an empty one) writes the **whole object**: the executor forwards
9+
`where: {}` plus the bulk intent, the data engine classifies that as a legal
10+
`multi` call, and it lands on `driver.deleteMany` / `driver.updateMany` with no
11+
predicate. Every row, every run.
12+
13+
That path only became authorable with #5393, which gave these nodes a bulk
14+
declaration at all — before it the executor never passed `options.multi`, so the
15+
engine refused every predicate write (`Delete requires an ID or
16+
options.multi=true`) and "empty filter + bulk intent" was not a reachable shape.
17+
Since then it has been reachable and **silent**: `filter` is optional, `multi` is
18+
optional, nothing related the two, and the author's only feedback was the step's
19+
`acted` row count — reported after the rows were gone. The common way to get
20+
here is not malice but an omission: declaring the bulk intent and forgetting the
21+
constraint.
22+
23+
`os validate` / `os build` now report `flow-multi-write-unfiltered` for it:
24+
25+
```
26+
flow 'nightly_purge' · node 'purge' (delete_record)
27+
declares `multi: true` with no `filter` key — this is a WHOLE-OBJECT write,
28+
by declaration: every row of 'lead' is deleted on every run. …
29+
```
30+
31+
**A warning, not a gate.** An explicit whole-object purge is something the
32+
platform grants on purpose — the data engine's own dispatch case-set lists "bulk
33+
intent with no predicate at all" as a valid call — so the shape has a legitimate
34+
reading and the run-time path stays open. What was missing was only that the
35+
author hears about it *before* the rows go. For the same reason the fix is not a
36+
schema `refine`: forbidding the shape would delete an intent the engine grants.
37+
38+
Two ways to satisfy the warning: write the constraint you mean into `filter`
39+
(the bounded-bulk reference shape is app-showcase's `showcase_inquiry_purge`), or
40+
confirm that emptying the object is the intent and keep it.
41+
42+
**It does not duplicate the #3810 run-time guard, which judges a different
43+
fact.** That guard refuses a node when a condition the author *wrote*
44+
interpolated to nothing (`{record.ownr}` — a typo — leaving `{}`), and it is
45+
deliberately keyed on "a written condition is gone" rather than on "the filter is
46+
empty", because losing one of two conditions also widens the blast radius. So:
47+
48+
| fact | judged by | when | verdict |
49+
|-------------------------------|--------------------|-----------|---------|
50+
| a written condition vanished | #3810 filter guard | run time | refuse |
51+
| no condition was ever written | this rule | authoring | warn |
52+
53+
A node with `filter: { owner: '{record.ownr}' }` is silent for this rule (a
54+
condition *is* written) and refused by that one; a node with no `filter` at all
55+
is warned about here and — correctly — allowed there. The diagnostic names the
56+
run-time guard so the two are not mistaken for one check.
57+
58+
Reported at every nesting depth, which matters because a scheduled sweep whose
59+
per-item work sits in a `loop` body is the standard janitor shape: a finding
60+
inside a region carries the region scope (`flow 'x' · loop 'sweep' body · node
61+
'purge' (delete_record)`), on the traversal #5383/#5635 added to this family.
62+
63+
Deliberately out of range: an empty **combinator** array (`{ $and: [] }`,
64+
`{ $or: [] }`). #5322/#5134 ruled those and every driver implements the ruling —
65+
empty `$and` is TRUE (so it *is* a whole-object write), empty `$or` is FALSE (so
66+
it matches nothing and must never be warned about) — but telling them apart
67+
requires the boolean-identity reduction, which already exists producer-side in
68+
each driver. A hand-written fourth copy inside a linter is how a scan and a
69+
validator come to answer with two different predicates, so that case is tracked
70+
separately instead.

0 commit comments

Comments
 (0)