Skip to content

Commit 63a6167

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-4778-approval-lock-multi-update
2 parents 677eeb0 + c4ab50b commit 63a6167

8 files changed

Lines changed: 680 additions & 51 deletions

File tree

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
---
2+
"@objectstack/metadata": patch
3+
---
4+
5+
fix(metadata): `sys_metadata` 的 DDL 失败不再被静默吞掉 —— 只有「表已存在」这一种原因可以静音 (#4728)
6+
7+
`DatabaseLoader.ensureSchema()` 过去用一个空 `catch` 吞掉 **全部** DDL 失败,并且照样把
8+
`schemaReady` 置为 `true`:
9+
10+
```ts
11+
} catch {
12+
// If syncSchema fails (e.g. table already exists), mark ready and continue
13+
this.schemaReady = true;
14+
}
15+
```
16+
17+
注释里的免责理由只覆盖了失败原因中最良性的一种,却用它为**所有**原因开脱。真实的失败
18+
(权限不足、数据源根本没连上、列类型冲突)之后,表或新列压根不存在,而进程的状态与成功
19+
路径**逐字节相同**,启动日志里一行痕迹都没有 —— 这正是 #4420 的形态:声称已持久化、实
20+
际没落盘、系统看起来完全健康。#4632 把它定成规则(AGENTS.md → "Degradation log levels"),
21+
机械检查 `pnpm check:durability-log-level` 已经能发现这一处。
22+
23+
现在按**错误类型**判别,而不是按注释里的乐观假设:
24+
25+
- **良性的「已存在」**(SQLite 的 `table … already exists` / `duplicate column name`
26+
Postgres 的 SQLSTATE `42P07`/`42701`/`42710`、MySQL 的 `ER_TABLE_EXISTS_ERROR` 等及其
27+
`errno`,并跟随 `cause` 链)—— 表确实已就绪,当作 no-op 静默通过,并照常执行后续的
28+
`project_id → environment_id` 迁移与 ADR-0005 索引。
29+
- **其余一切失败** —— 以 `console.error` 上报,文案同时说清**后果**(`sys_metadata` 的表/
30+
列未创建,后续每一次元数据写入都会报错、或在宽松驱动上悄悄丢列,而服务器仍报告健康)
31+
**修复动作**(修掉下面那条驱动/数据源错误后重启)。只说**一次**,不是每次写入都刷屏。
32+
- `schemaReady` **不再**在真实失败后置 `true`。启动依旧不被阻断(该方法不抛),但 loader
33+
不再声称一个它并不具备的就绪状态,下一次元数据操作会重试 —— 数据源只是还在连接这类瞬
34+
时故障因此可以自愈,恢复时补一条 `info`
35+
36+
`ensureHistorySchema()` 按同一规则对齐:良性「已存在」不再每次写入都打一条 `error`(过度
37+
使用 `error` 是镜像失败),真实失败则同样只响亮一次并保持重试。
38+
39+
无 API / schema 变更;新增内部工具 `isSchemaAlreadyExistsError()`(未从包入口导出)。
40+
`scripts/durability-degradation.baseline.json` 中指向本单的条目随之删除(该文件 shrink-only)。
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
---
2+
---
3+
4+
docs(protocol): `protocol/kernel/http-protocol` 的 API Discovery 一节拆成两段式 —— `@objectstack/rest` 服务的 `/api/v1`(与 `/api/v1/discovery`)与 dispatcher 服务的 `/.well-known/objectstack` 各给一份真实响应形状,不再共用一份混合示例。Docs-only;releases nothing.

content/docs/protocol/kernel/http-protocol.mdx

Lines changed: 121 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -19,51 +19,148 @@ The **HTTP API** defines how ObjectStack maps data operations to RESTful HTTP en
1919

2020
## API Discovery
2121

22-
### Discovery Endpoint
22+
Before making any API calls, clients should request a discovery endpoint to learn about
23+
available services. **Two endpoints answer that question, and in a stack that mounts
24+
`@objectstack/rest` they do not return the same shape** — they are built by different
25+
packages. Read the one that matches your composition; do not mix their fields.
2326

24-
Before making any API calls, clients should request the discovery endpoint to learn about available services:
27+
### `GET /api/v1` (and `GET /api/v1/discovery`)
2528

26-
**Request:**
27-
```http
28-
GET /.well-known/objectstack HTTP/1.1
29-
Host: api.acme.com
30-
```
29+
Returns the full discovery manifest. `@objectstack/rest` registers **one handler at both
30+
paths** — the API base path and `<basePath>/discovery` — so the two are the same document,
31+
not a redirect and not two shapes. In a REST-less composition the runtime dispatcher
32+
registers `<basePath>/discovery` as the fallback owner instead, and then serves its own
33+
`/.well-known/objectstack` payload there (see below); when `@objectstack/rest` is mounted
34+
the dispatcher cedes the route to it, so a single owner answers it (ADR-0076 D11).
3135

32-
`/.well-known/objectstack` and the versioned `/api/v1/discovery` route both return the
33-
discovery document directly — there is no HTTP redirect between them:
36+
**Request:**
3437
```http
3538
GET /api/v1/discovery HTTP/1.1
39+
Host: api.acme.com
3640
```
3741

3842
**Response:**
3943
```json
4044
{
41-
"name": "Acme CRM Production",
42-
"version": "2.1.0",
43-
"environment": "production",
45+
"version": "v1",
46+
"apiName": "ObjectStack API",
4447
"routes": {
4548
"data": "/api/v1/data",
46-
"metadata": "/api/v1/meta",
47-
"packages": "/api/v1/packages",
48-
"auth": "/api/v1/auth",
49-
"ui": "/api/v1/ui",
50-
"storage": "/api/v1/storage"
49+
"metadata": "/api/v1/meta"
5150
},
5251
"services": {
53-
"data": { "enabled": true, "status": "available", "route": "/api/v1/data", "provider": "objectql" },
54-
"metadata": { "enabled": true, "status": "available", "route": "/api/v1/meta", "provider": "objectql" },
55-
"auth": { "enabled": true, "status": "available", "route": "/api/v1/auth", "provider": "@objectstack/plugin-auth" },
52+
"metadata": { "enabled": true, "status": "available", "handlerReady": true, "route": "/api/v1/meta", "provider": "objectql" },
53+
"data": { "enabled": true, "status": "available", "handlerReady": true, "route": "/api/v1/data", "provider": "objectql" },
5654
"search": { "enabled": false, "status": "unavailable", "message": "No implementation ships for the 'search' slot — register a service under it to enable" },
5755
"ai": { "enabled": false, "status": "unavailable", "message": "Provided by @objectstack/service-ai in ObjectStack Cloud/Enterprise — no implementation ships in the open framework" }
5856
},
59-
"locale": {
60-
"default": "en-US",
61-
"supported": ["en-US", "zh-CN", "es-ES", "fr-FR"],
62-
"timezone": "America/Los_Angeles"
57+
"capabilities": {
58+
"cron": { "enabled": false },
59+
"automation": { "enabled": false },
60+
"search": { "enabled": false },
61+
"transactionalBatch": { "enabled": true, "description": "Atomic cross-object batch endpoint (POST {basePath}/batch)…" }
62+
},
63+
"scoping": {
64+
"enabled": false,
65+
"resolution": "auto",
66+
"scoped": false
67+
}
68+
}
69+
```
70+
71+
Three things about this body are worth stating explicitly, because they are what the
72+
`/.well-known/objectstack` document below does *not* share:
73+
74+
- **`version` is the configured API version, not a product version.** The handler
75+
overwrites the protocol's value with `api.version` — the same string that forms the
76+
path segment (`"v1"`). It is never a semantic version like `2.1.0`.
77+
- **There is no `name`, `environment` or `locale` here.** Those are dispatcher fields
78+
(see below). A client that initialises i18n from `locale` must read
79+
`/.well-known/objectstack`, not this response.
80+
- **`scoping` is added by the REST server**, so clients can detect dual-mode routing;
81+
`environmentId` is present only on the environment-scoped mount
82+
(`/api/v1/environments/:environmentId/...`).
83+
84+
Disabled/uninstalled route keys are omitted from `routes` entirely rather than set to
85+
`null`; check `services` to tell "not installed" apart from "installed but not yet mounted
86+
here." `capabilities` is a flat map of platform feature flags (`comments`, `automation`,
87+
`cron`, `search`, `export`, `chunkedUpload`, `transactionalBatch`), each derived from what
88+
is actually registered — never hardcoded. See
89+
[API → Discovery](/docs/api#discovery) for the field-by-field reference.
90+
91+
### `GET /.well-known/objectstack`
92+
93+
Served by the runtime dispatcher (`@objectstack/runtime`), not `@objectstack/rest` — its
94+
body is wrapped as `{ "data": { ... } }` and includes fields (`name`, `environment`,
95+
`features`, `locale`) that the `@objectstack/rest`-served `/api/v1` response above does
96+
not. This path is unconditionally dispatcher-owned: no other plugin registers it, so it
97+
answers with this shape whether or not REST is mounted. The client SDK's `connect()` tries
98+
`/api/v1/discovery` first and falls back to this endpoint, unwrapping either `body.data` or
99+
the bare `body`.
100+
101+
**Request:**
102+
```http
103+
GET /.well-known/objectstack HTTP/1.1
104+
Host: api.acme.com
105+
```
106+
107+
**Response:**
108+
```json
109+
{
110+
"data": {
111+
"name": "ObjectOS",
112+
"version": "1.0.0",
113+
"environment": "production",
114+
"routes": {
115+
"data": "/api/v1/data",
116+
"metadata": "/api/v1/meta",
117+
"packages": "/api/v1/packages",
118+
"auth": "/api/v1/auth",
119+
"ui": "/api/v1/ui",
120+
"i18n": "/api/v1/i18n"
121+
},
122+
"features": {
123+
"search": false,
124+
"websockets": false,
125+
"files": false,
126+
"analytics": false,
127+
"ai": false,
128+
"notifications": false,
129+
"i18n": true
130+
},
131+
"services": {
132+
"metadata": { "enabled": true, "status": "available", "handlerReady": true, "route": "/api/v1/meta", "provider": "kernel" },
133+
"data": { "enabled": true, "status": "available", "handlerReady": true, "route": "/api/v1/data", "provider": "kernel" },
134+
"auth": { "enabled": true, "status": "available", "handlerReady": true, "route": "/api/v1/auth" },
135+
"search": { "enabled": false, "status": "unavailable", "handlerReady": false, "message": "No implementation ships for the 'search' slot — register a service under it to enable" }
136+
},
137+
"locale": {
138+
"default": "en-US",
139+
"supported": ["en-US", "zh-CN"],
140+
"timezone": "UTC"
141+
}
63142
}
64143
}
65144
```
66145

146+
`name` and `version` are the dispatcher's own build identity, not your app's name — they
147+
are fixed strings, so do not display them as the deployment's title. `environment` is the
148+
process `NODE_ENV`. `locale` is derived from the registered i18n service (`getDefaultLocale()`
149+
/ `getLocales()`); with no i18n service it degrades to `{ "default": "en", "supported":
150+
["en"], "timezone": "UTC" }`. The body also repeats `routes` under an `endpoints` key as a
151+
backward-compatibility alias, and carries **no** `capabilities` map — that one exists only
152+
on the REST-served response above.
153+
154+
<Callout type="warn">
155+
**"Both paths return the same document" holds only in a REST-less composition.** There, the
156+
dispatcher owns `/api/v1/discovery` as the fallback registrant, so that path and
157+
`/.well-known/objectstack` both answer with the dispatcher payload above (the bare
158+
`/api/v1` is registered by `@objectstack/rest` alone and is not served at all). As soon as
159+
`@objectstack/rest` is mounted it takes `/api/v1/discovery` under the single-owner rule
160+
(ADR-0076 D11) and the two paths answer different shapes. Never write a client that reads
161+
`locale` or `environment` off `/api/v1/discovery`.
162+
</Callout>
163+
67164
**Why discovery matters:**
68165
- **Environment agnostic:** Works across dev, staging, production without hardcoding URLs
69166
- **Version tolerance:** API routes can change without breaking clients

0 commit comments

Comments
 (0)