Skip to content

Commit 9d39774

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-4700-host-resolver-shared
2 parents f928fd5 + 023c00b commit 9d39774

53 files changed

Lines changed: 2586 additions & 1260 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: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
---
2+
"@objectstack/plugin-auth": patch
3+
---
4+
5+
fix(plugin-auth): 每号码 OTP 发送预算改用惰性解析的共享计数存储 —— 多节点下不再按节点数倍增 (#4790)
6+
7+
#2780 的「每号码 OTP 发送预算」(60s 冷却 + 每小时 5 条)此前**只有宿主显式提供
8+
better-auth `secondaryStorage` 时才跨节点共享**`AuthManager.getOtpSendGuard()` 唯一的
9+
存储来源就是 `AuthManagerOptions.secondaryStorage`,而标准 `serve` 组合里没有任何一处
10+
提供它(#4788 之后 `AuthPlugin` 也明确不再从 cache 服务派生它)。于是预算落在**每个进程
11+
一份**:N 个节点的部署,一个号码实际能收到的是声明值的 N 倍,而且**没有任何信号**告诉你
12+
它没兑现(ADR-0049 声明 ≠ 强制)。这里的计价单位是**真金白银的短信**
13+
14+
这是 #4772 那条限流洞的同类,但是独立的一处:#4788 修的是 better-auth 自己的 `rateLimit`
15+
计数器(走 `rateLimit.customStorage`),OTP 预算是 ObjectStack 在 `AuthManager` 里自己实现
16+
的另一套计数,行为未被 #4788 改变。
17+
18+
**修法:复用 #4788 建好的那条路径,而不是再写一份。** `rate-limit-storage.ts` 中把「惰性
19+
解析 → 绑定即宣告 → 解析不到就降级到有界的进程内存储并响亮告警」抽成
20+
`createLazyCounterStore()``createLazyCacheRateLimitStorage()` 现在就是它的一层薄封装),
21+
OTP 预算经由新的 `AuthManagerOptions.sharedCounterStore` 接同一条路径:
22+
23+
- **存储在每次发送校验时才解析**,因此 `CacheServicePlugin` 晚于 `AuthPlugin` 注册也照样
24+
绑定得上(插件启动顺序不再决定任何事)—— 这正是 #4772 冻结结论造成的那个洞;
25+
- 配了 cache 的多节点部署,每号码预算**现在真的是一份**,换节点不会重新获得冷却额度;
26+
- 没有 cache 服务的部署**仍然限额**,只是降级为进程内计数,并在第一次真正计数时打一条
27+
点名代价的 warn(「an N-node deployment can send up to N× the configured number of PAID
28+
SMS to one number」)—— 降级不是关闭,两种情况在日志里可区分(绑定打 info,降级打 warn)。
29+
30+
**刻意不引入 `secondaryStorage` 来修它**#4785):那会把会话的记录之处搬进缓存,静默废掉
31+
ADR-0069 D4 的三个会话管控。宿主自己提供的 `secondaryStorage` 对这个预算仍然优先且行为不变。
32+
33+
冷却与滚动小时窗的语义**未做任何改动**:计数依旧是按号码的时间戳滚动窗口,只是换了它所在的
34+
存储。(固定窗口计数器无法表达「距上一次发送满 N 秒」,把它改成定窗会在窗口边界放行两倍突发
35+
——用一种倍增换另一种倍增。)
36+
37+
对使用者的影响:
38+
39+
- 新增 `AuthManagerOptions.sharedCounterStore``AuthPlugin` 自动填充,一般宿主无需感知;
40+
- 新增导出 `createLazyCounterStore()``counterStoreFromKv()`
41+
- `OtpSendGuard` 新增 `resolveStore` 选项,原有的 `storage`(字符串 KV)选项保持可用。
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
---
2+
"@objectstack/lint": minor
3+
---
4+
5+
feat(lint): `has(x)` 不是 null 守卫 —— 发布期直接拒绝未守卫的可空比较 (#4763)
6+
7+
CEL 的 `has(x)` 问的是**键是否存在**。自 #4649 起,谓词读到的记录对对象声明的每个
8+
字段都是**全量**的:一个声明了却存 `NULL` 的列同样"存在",所以
9+
`has(record.end_date)` 对声明字段恒为 `true`,什么也没告诉作者。于是这个读起来
10+
像守卫的写法根本不是守卫:
11+
12+
```text
13+
has(record.start_date) && has(record.end_date) && record.end_date < record.start_date
14+
```
15+
16+
它会走到 `null < null`,CEL 没有对应重载,整个谓词中断。#4761 之前中断被吞掉
17+
(规则跳过,一条 WARN),也就是说**这一形状的规则在任何含 null 值的行上从未生效
18+
**——它写在元数据里、读起来完全正确、却什么都没有强制执行。#4761 把运行时改成
19+
fail-closed 之后,当场就在我们自己的两个示例对象里抓到了它。
20+
21+
运行时拒绝是兜底,不是该学到这件事的地方:作者会在真实数据(很可能是生产数据)
22+
上收到一个 400,离写下规则可能已经过去几个月。而这个错误**仅凭元数据就可判定**
23+
——谓词的 AST 加上对象声明的字段类型,就足以判断某个操作数是否可能为 null。按
24+
AGENTS.md PD #12(在创作期拒绝,不要在消费端容忍),它属于发布闸门。
25+
26+
**新增闸门(error,直接拒绝,没有降级开关)。** `os build` / `os validate` /
27+
`os lint` 与运行时发布闸门共用的 `validateStackExpressions` 现在会拒绝这样的谓词:
28+
**声明为可空**的字段(没有 `required: true`、没有 `defaultValue`、没有默认选项、
29+
不是 autonumber)应用**排序**(`< <= > >=`)或**算术**(`+ - * / %`,含一元 `-`)
30+
运算符,而该操作数没有被同一布尔分支内支配它的 `!= null` / `== null` / `!isBlank()`
31+
显式判空所守卫。`has(x)` **刻意不**计入守卫——这正是本规则存在的理由。错误信息点名
32+
规则、操作数与修法,收尾句逐字取自 `rule-validator.ts``unevaluableRuleError`,
33+
两道闸门措辞完全一致。
34+
35+
覆盖面(有意划定,而不是含糊地覆盖一半):对象**校验规则**(含 `conditional` 规则
36+
`then` / `otherwise` 里嵌套的谓词)与**生命周期 hook 的 `condition`** ——即真正由 CEL
37+
在全量记录上求值、会 fail-closed 的两类面。共享规则条件(下推成 SQL 过滤,`NULL > x`
38+
是三值逻辑,不会 fault)、flow 的扁平作用域条件(裸标识符可能是 flow 变量)与
39+
`Field.formula`(有自己的 #3306 `guard ? value : null` 处理)不在此列。
40+
41+
**未声明**键的 `has()` 完全不受影响——那才是它的正当用途:区分"这次 PATCH 里
42+
根本没提到这个键"与"显式写了 null"。示例应用无需改动即通过新闸门。
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
---
2+
'@objectstack/objectql': minor
3+
'@objectstack/cli': patch
4+
---
5+
6+
修复:每个 `os migrate` 子命令关停后,#4551 悬空引用巡检都会把 `sys_metadata` / `sys_view_definition` 报成 `unreadableObjects`(#4747)
7+
8+
一条**成功**的命令过去会在返回 JSON 之后打出两行 `ERROR Find operation failed` 和一份
9+
`unreadableObjects` 非空的巡检报告 —— 对抓 ERROR 的 CI 流水线是直接误报源,更要命的是它把
10+
`unreadableObjects` 变成了恒为真的告警:那个桶存在的意义正是区分「我没能检查」和「我检查了,
11+
没问题」,一个每次健康运行都非空的桶不再携带任何信息。
12+
13+
两处静默空转叠出了这个结果:
14+
15+
- `ObjectQLPlugin` 的关停逻辑写在 `stop()` 里,而内核的插件契约是 `init`/`start`/`destroy` ——
16+
`stop()` 从来没有被任何人调用过,ADR-0057 巡检定时器因此在任何宿主上都不会被解除。改为
17+
`destroy()`(与 `DefaultDatasourcePlugin` 一致)。
18+
- `bootSchemaStack().shutdown()` 调的是 `(runtime as any).stop?.()`,而 `Runtime` 根本没有
19+
`stop` —— 可选调用把「没有关停」伪装成了「关停过了」。改为走内核自己的 `kernel.shutdown()`,
20+
`os serve` 收到 SIGTERM 时同一条路径。
21+
22+
同时 `LifecycleService.stop()` 不再只是清定时器:它还会把「引擎正在拆」这一位交给正在飞行中的
23+
sweep,巡检据此在读之前停手。因关停而失败的读**不再进入** `unreadableObjects` —— 那不是关于
24+
数据源的证据;报告改用新增的 `DanglingReferenceReport.aborted` 记录「这次没跑完」,所以不完整
25+
依然是响的,只是不再占用发现桶。
26+
27+
**真正读不出来的对象(数据源故障)照旧进 `unreadableObjects`**,巡检在 CLI 场景也照旧运行 ——
28+
这里没有「一次性命令不跑巡检」的开关,只有「引擎活着才读」的生命周期边界。
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
---
2+
---
3+
4+
chore(i18n): drop the undeclared `name:` key from all nine `scripts/i18n-extract.config.ts`
5+
6+
Releases nothing — build-time-only extract fixtures (`scripts/` is not in any
7+
package's published `files`), no runtime or published behaviour changes.
8+
9+
Every one of the nine extract configs opened its `defineStack({ … })` with a
10+
`name:` that the stack schema does not declare, so `ObjectStackDefinitionSchema`
11+
dropped the value at load and the #4167 unknown-stack-key lint reported it —
12+
once per package, on every `pnpm check:i18n` run, in a run that was otherwise
13+
fully green:
14+
15+
```
16+
defineStack: stack.name: 'name' is not a declared stack key, so its value is dropped at load — did you mean 'pages'?
17+
```
18+
19+
The lint was right and the configs were wrong: nothing has ever read a stack's
20+
top-level `name``os i18n extract` receives the *parsed* `defineStack` result,
21+
from which the key is already gone — so the nine values were inert. The fix is
22+
at the producer (#4736 decision A: delete the nine keys), not a new authorable
23+
key in `packages/spec` to accommodate one typo copied nine times.
24+
25+
Extraction output is unchanged: after the deletion a full
26+
`node scripts/check-i18n-bundles.mjs --write` regenerates all 40 bundles across
27+
the nine packages with a byte-identical result, and `pnpm check:i18n` stays
28+
green — now without the warning.
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
---
2+
"@objectstack/spec": major
3+
---
4+
5+
feat(spec)!: `@objectstack/spec/system` no longer exports the orphan notification-template vocabulary — `EmailTemplate(Schema)`, `SMSTemplate(Schema)`, `PushNotification(Schema)`, `InAppNotification(Schema)` (#4616)
6+
7+
These four schemas existed **only** as the member shapes of the
8+
`NotificationConfigSchema.template` union, and #4610 (#4535 C3) deleted that
9+
union. Since then they have been reachable from no parent schema and from no
10+
metadata-type root: nothing in framework, cloud or objectui parsed a document
11+
against them, so they declared delivery capability the runtime never read
12+
(ADR-0049 enforce-or-remove, resolved by REMOVE in the v17 breaking window).
13+
14+
Migration — one line each, and in every case the replacement already exists:
15+
16+
- FROM `import { EmailTemplateSchema, type EmailTemplate } from '@objectstack/spec/system'`
17+
TO `import { EmailTemplateDefinitionSchema, type EmailTemplateDefinition } from '@objectstack/spec/system'`.
18+
**Shape change** — this is a different, richer contract, not a rename:
19+
`EmailTemplateDefinitionSchema` is keyed `name` + `locale` (not `id`), splits
20+
the body into `bodyHtml` / `bodyText` (not `body` + `bodyType`), and adds
21+
`label` / `category` / `active` / `fromOverride` / `replyTo`. It is also a
22+
`strictObject`, so the old keys are rejected loudly rather than stripped.
23+
This is the schema the `email_template` metadata kind has resolved to since
24+
spec **7.1.0**, which demoted `EmailTemplateSchema` when it fixed that Prime
25+
Directive #8 double-declaration and kept it "only as an inline sub-shape
26+
inside `Notification`" — #4610 removed that holder, and #4616 finishes the
27+
job. If your code registers a client-side or publish-time validator for
28+
`email_template`, it must point at `EmailTemplateDefinitionSchema`;
29+
`BUILTIN_METADATA_TYPE_SCHEMAS` (`kernel/metadata-type-schemas.ts`) is the
30+
authority.
31+
- FROM `import { SMSTemplateSchema, type SMSTemplate } from '@objectstack/spec/system'`
32+
TO: no spec replacement, and none is needed. SMS templates are
33+
`sys_notification_template` rows resolved by `(topic, 'sms', locale)`
34+
(`service-messaging/src/sms-channel.ts`) and rendered by
35+
`template-renderer.ts`; the provider-side template is Aliyun's pre-registered
36+
`TemplateCode` in `service-sms` — a vendor API shape, never a spec constant.
37+
- FROM `import { PushNotificationSchema, type PushNotification } from '@objectstack/spec/system'`
38+
and FROM `import { InAppNotificationSchema, type InAppNotification } from '@objectstack/spec/system'`
39+
TO: no replacement. Neither channel has a delivery implementation (#3197):
40+
the dispatcher dead-letters any message addressed to them, so these payload
41+
shapes advertised a capability nothing delivers. The live delivery ingress is
42+
`NotificationService.emit` (`INotificationService`,
43+
`@objectstack/spec/contracts`); the in-app bell reads `./api`'s
44+
`Notification(Schema)` inbox row; the presentation vocabulary is
45+
`@objectstack/spec/ui` (`NotificationTypeSchema`, `NotificationSeveritySchema`,
46+
`NotificationPositionSchema`, `NotificationActionSchema` — all unchanged).
47+
48+
Unchanged and explicitly NOT part of this removal:
49+
`@objectstack/spec/system`'s `NotificationChannel(Schema)` (live — re-exported
50+
by `@objectstack/spec/contracts`, consumed by `service-messaging`),
51+
`EmailTemplateDefinition*`, and every `@objectstack/spec/ui` notification
52+
export.
53+
54+
No ADR-0087 D2 conversion accompanies this change, deliberately: a conversion
55+
rewrites authored or stored sources, and these defs were reachable from no
56+
metadata-type root, so `os migrate meta` would have nothing to match. The
57+
removal is a TypeScript export-surface break only — same disposition as #4610
58+
in this very module. `json-schema.manifest.json` loses 4 keys and
59+
`authorable-surface.json` loses their 22 lines; both deletions are adjudicated
60+
by `gen:schema`'s #4650 route-3 check ("def no longer emitted by this build").
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
---
2+
---
3+
4+
docs(protocol): retire `protocol/kernel/runtime-capabilities` — the page taught `ObjectStackCapabilities`, a schema removed in #3605. Docs-only; releases nothing.

content/docs/api/index.mdx

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -98,9 +98,14 @@ Schema reference: [API](/docs/references/api)
9898

9999
The discovery endpoint is the entry point for all clients. It returns the API version, available routes, service capabilities, and per-service status.
100100

101-
### `GET /api/v1`
101+
### `GET /api/v1` (and `GET /api/v1/discovery`)
102102

103-
Returns the full discovery manifest.
103+
Returns the full discovery manifest. `@objectstack/rest` registers **one handler at both
104+
paths** — the API base path and `<basePath>/discovery` — so the two are the same document,
105+
not a redirect and not two shapes. In a REST-less composition the runtime dispatcher
106+
registers `<basePath>/discovery` as the fallback owner instead, and then serves its own
107+
`/.well-known/objectstack` payload there (see below); when `@objectstack/rest` is mounted
108+
the dispatcher cedes the route to it, so a single owner answers it (ADR-0076 D11).
104109

105110
**Response**:
106111
```json
@@ -125,7 +130,8 @@ Returns the full discovery manifest.
125130
"capabilities": {
126131
"cron": { "enabled": false },
127132
"automation": { "enabled": false },
128-
"search": { "enabled": false }
133+
"search": { "enabled": false },
134+
"transactionalBatch": { "enabled": true, "description": "Atomic cross-object batch endpoint (POST {basePath}/batch)…" }
129135
}
130136
}
131137
```
@@ -134,6 +140,15 @@ Disabled/uninstalled route keys (e.g. `auth`, `analytics`, `workflow`) are omitt
134140

135141
`metadata` is reported from whatever implementation fills its slot, so the sample's `available` is the `MetadataPlugin` case (a persisted `sys_metadata` registry). A stack running the kernel's in-memory fallback instead reports `status: "degraded"` with a `message` naming what is missing and what to install. `handlerReady` is `true` either way: `/api/v1/meta` is served by the protocol, so the route is mounted whichever registry sits behind it.
136142

143+
`capabilities` is a flat map of platform feature flags, one entry per well-known
144+
capability (`comments`, `automation`, `cron`, `search`, `export`, `chunkedUpload`,
145+
`transactionalBatch`), each derived from what is actually registered — never hardcoded.
146+
`transactionalBatch` (#3298, ADR-0034) is the one worth negotiating at connect time: it is
147+
`true` **iff** the atomic cross-object batch route (`POST {basePath}/batch`) is mounted
148+
*and* the runtime engine can honour a transaction, so a client can decide once whether to
149+
send an atomic batch or fall back to client-side sequencing, instead of probing for
150+
`404`/`405`/`501`. See [Data API → batch](/docs/api/data-api).
151+
137152
### `GET /.well-known/objectstack`
138153

139154
Served by the runtime dispatcher (`@objectstack/runtime`), not `@objectstack/rest` — its body is wrapped as `{ "data": { ... } }` and includes fields (`name`, `environment`, `features`, `locale`) that the `@objectstack/rest`-served `/api/v1` response above does not. The client SDK's `connect()` tries `/api/v1/discovery` first and falls back to this endpoint, unwrapping either `body.data` or the bare `body`.

content/docs/automation/index.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,6 @@ Rule of thumb: model *state* with workflows, model *steps* with flows, use hooks
5252

5353
## Related
5454

55-
- **Spec:** [State Machine (Lifecycle)](/docs/protocol/objectql/state-machine), [Runtime Capabilities](/docs/protocol/kernel/runtime-capabilities)
55+
- **Spec:** [State Machine (Lifecycle)](/docs/protocol/objectql/state-machine), [HTTP API](/docs/protocol/kernel/http-protocol)
5656
- **Schema reference:** [Automation](/docs/references/automation)
5757
- **Neighbors:** validation rules that block bad data live in [Data Modeling](/docs/data-modeling/validation); who may trigger an automation is governed by [Permissions & Identity](/docs/permissions); the services hooks call (email, queue, storage…) are documented in [Kernel & Services](/docs/kernel/runtime-services).

content/docs/data-modeling/validation.mdx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,10 @@ export const Order = ObjectSchema.create({
5959
Condition expressions are **CEL** (evaluated by `@objectstack/formula`), not Salesforce-style formulas. Reference the incoming record via `record.<field>`, use `==`/`!=`, `&&`/`||`, and helpers like `isBlank(x)` and `has(record.field)`. A string condition is accepted as authoring shorthand and normalized to `{ dialect: 'cel', source }` at build time.
6060
</Callout>
6161

62+
<Callout type="warn">
63+
**`has(x)` is not a null guard.** Predicates see a record that is *total* over the object's declared fields, so `has(record.end_date)` is true even when the value is `NULL``has(a) && has(b) && a < b` then reaches `null < null`, CEL has no overload, and the whole rule aborts (the write is rejected fail-closed). Write `record.start_date != null && record.end_date != null && record.end_date < record.start_date` instead. Since #4763 the `has()` form is **rejected at build/publish**: an ordering or arithmetic operator over a declared nullable field needs a real `!= null` guard. `has()` over an *undeclared* key — "was this in the PATCH at all?" — is untouched.
64+
</Callout>
65+
6266
## Common Properties
6367

6468
All validation types share these base properties:

content/docs/kernel/index.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,6 @@ The kernel is ObjectStack's runtime: it loads your metadata artifact, hosts plug
4040

4141
## Related
4242

43-
- **Spec:** [System Lifecycle](/docs/protocol/kernel/lifecycle), [Runtime Capabilities](/docs/protocol/kernel/runtime-capabilities), [Metadata Service](/docs/protocol/kernel/metadata-service)
43+
- **Spec:** [System Lifecycle](/docs/protocol/kernel/lifecycle), [Metadata Service](/docs/protocol/kernel/metadata-service)
4444
- **Schema reference:** [Kernel](/docs/references/kernel), [System](/docs/references/system), [Contracts](/docs/references/contracts)
4545
- **Neighbors:** building and packaging plugins is covered in [Plugins & Packages](/docs/plugins); running the kernel in production is covered in [Deployment & Operations](/docs/deployment).

0 commit comments

Comments
 (0)