Skip to content

Commit e6d64d1

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-4793-batch-row-result-schema-work
2 parents f3e2f40 + 7445149 commit e6d64d1

45 files changed

Lines changed: 3180 additions & 529 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
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.
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
---
2+
"@objectstack/lint": minor
3+
---
4+
5+
feat(lint): 视图 `searchableFields` 按运行时同一套判定做构建期校验 —— 一个 lookup 笔误不再等到 400 才暴露 (#4830)
6+
7+
视图(list view)的 `searchableFields` 会被客户端逐字回显为 `$searchFields` 覆盖参数,而
8+
REST 入口闸(#4254)会用 `resolveSearchFieldResolution`(`@objectstack/spec/data`)判定
9+
该对象的可搜索集合 —— 声明一个 lookup 等「不可搜索」字段,运行时会把**整条查询** 400
10+
(`INVALID_FIELD`),列表工具栏搜索对全体角色彻底不可用。此前 `compile`/`validate` 只查
11+
字段**存在性**,这类笔误全绿放行,只能靠人肉点搜索框发现。
12+
13+
新增规则 `searchable-field-unsearchable`(error 级,新导出常量同名):对每个视图级
14+
narrowing(对象内建 `listViews``defineView``list`/`listViews`、react 页面的
15+
`<ListView searchableFields>`)按**运行时同一个函数**(`resolveSearchFieldResolution`,
16+
非复制的类型清单,杜绝再度漂移)判定 declared = enforced:
17+
18+
- 对象未声明 `searchableFields`(auto 源):视图里出现 lookup/json/hidden/审计列等
19+
auto-default 拒绝的字段 → 构建期 error,信息含类型与 400 后果,lookup 给出「镜像到本
20+
对象 text/formula 字段」的处方;
21+
- 对象已声明(declared 源):视图条目超出对象声明集合 → 构建期 error(视图只能收窄、
22+
不能放宽,ADR-0061);
23+
- 对象自身的 `searchableFields`(canonical)维持**只查存在性**:运行时 declared 分支按
24+
存在过滤、不按类型过滤,声明即被引擎执行,构建期拒绝会误伤运行时接受的元数据
25+
(ADR-0072 D1);
26+
- 注册表注入的系统列在 narrowing 中跳过判定(其运行时元数据对 linter 不可见,宁可漏报
27+
不可误报)。
28+
29+
内部核心 `checkSearchableFieldList` / `indexObjectSearchTargets`(模块级导出,未入包
30+
barrel)签名有变:索引值从 `Set<string> | null` 变为 `ObjectSearchTarget | null`,并新增
31+
可选 `role: 'canonical' | 'narrowing'`(默认 `'narrowing'`)参数。
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
---
2+
"@objectstack/spec": major
3+
---
4+
5+
refactor(spec)!: remove `activationEvents` (both keys) and the `ActivationEventSchema` vocabulary — lazy activation that no runtime ever implemented (#4657, ADR-0049)
6+
7+
`activationEvents` promised lazy plugin activation ("plugins remain dormant
8+
until an activation event fires") on two authorable surfaces —
9+
`DynamicLoadRequest.activationEvents` (`@objectstack/spec/kernel`) and
10+
`StudioPluginManifest.activationEvents` (`@objectstack/spec/studio`, the
11+
`defineStudioPlugin` input) — and **no runtime in objectstack / cloud /
12+
cloud-v1 / objectui ever read either key** (four-repo bare-name scan in #4657,
13+
re-verified at implementation time). Every plugin has always activated
14+
immediately on load/registration; cloud-v1's own ROADMAP recorded lazy
15+
activation as ❌ unimplemented (planned v0.4.0). That is ADR-0049's
16+
declared ≠ enforced shape in the semantically-lying direction: an author
17+
writing `activationEvents: [{ type: 'onMetadataType', pattern: 'flow' }]`
18+
expected deferral and got eager activation with a clean parse.
19+
20+
#4653 had just converged the two `ActivationEventSchema` declarations onto one
21+
structured `{ type, pattern }` form inside this same unreleased major; with the
22+
enforce-or-remove ruling landing on **remove**, that converged vocabulary
23+
retires before ever shipping. Composed across the two changes, a v16 author
24+
simply deletes the key in whichever form they carried.
25+
26+
Migration (FROM → TO):
27+
28+
- `activationEvents` in a `defineStudioPlugin` input / `StudioPluginManifest`
29+
value — v16 string form (`['*']`, `['onMetadataType:flow']`) or v17-rc
30+
structured form (`[{ type: 'onStartup', pattern: '*' }]`) alike →
31+
**delete the key**. There is no replacement value: eager activation is the
32+
only behaviour there has ever been, and `activate()` still runs at
33+
registration time. The strict manifest parse rejects the key (and its former
34+
VS Code-flavoured aliases `activation` / `events` / `onActivate`) with this
35+
prescription.
36+
- `activationEvents` in a `DynamicLoadRequest` value → **delete the key**.
37+
Tombstoned, not silently stripped — `DynamicLoadRequestSchema` is not
38+
`.strict()`, so a `retiredKey()` tombstone makes authoring it a `tsc` error
39+
and a parse error carrying the prescription.
40+
- `import { ActivationEventSchema, ActivationEvent } from '@objectstack/spec/kernel'`
41+
(or `/studio`) → **no replacement export** (TS2305 after upgrade). Nothing
42+
consumed the vocabulary; an exported schema with no consumer is read as a
43+
capability by whoever finds it (#3950), so the orphaned def goes with the
44+
keys.
45+
- Lazy activation is a **new capability**: if it is ever built it returns via
46+
the enforce route of ADR-0049 through a new ADR — executor first, vocabulary
47+
second — not by re-declaring inert keys.
48+
49+
Self-check (#4535 §5): TS2305 — yes, two removed exports on two entries;
50+
metadata migration — none possible or needed (`StudioPluginManifest` is TS
51+
configuration parsed by `defineStudioPlugin`, a root schema never stored in
52+
`sys_metadata`; `DynamicLoadRequest` is a runtime request shape with no
53+
caller — no stored row exists for a D2 conversion to rewrite, so the change is
54+
one ADR-0087 D3 semantic record, `plugin-activation-events-retired`); shape
55+
change — two keys removed, zero behaviour change (eager activation before and
56+
after, byte-identical).
57+
58+
The retirement kit: `retiredKey()` tombstone on the non-strict kernel schema;
59+
strict-parse `guidance` prescriptions on the studio manifest (including the
60+
three former aliases); ADR-0087 D3 semantic migration; baselines
61+
(`authorable-surface.json` — one `[RETIRED]` line, five lines dropped
62+
deliberately with the defs; `json-schema.manifest.json``kernel/ActivationEvent`
63+
and `studio/ActivationEvent` def removals; `api-surface.json`) regenerated
64+
deliberately; compiler-API export pin (`activation-events-retirement.test.ts`,
65+
zero holders across every public entry) — sabotage-verified.
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
---
3+
4+
Tooling-only: `pnpm check:startup-registry-verdict` — startup registry reads may not record a verdict the boot can still contradict (#4777). Adds `scripts/check-startup-registry-verdict.mjs` + the shrink-only `scripts/startup-registry-verdict.baseline.json` (empty on landing), a `Lint & Type Check` step, and an AGENTS.md section. Releases nothing — no package changes.
5+
6+
One showcase cold start on 2026-08-03 produced three instances of one shape in three unrelated subsystems written by three people at three times: ask a registry "is X there?" while the boot is still filling it, treat the "no" as final, and **record** it — cached on the instance (#4772 plugin-auth), asserted in a `warn` (#4771 service-automation), or written to the database (#4769 objectql). The provider registers a moment later and nothing undoes the record. All three are fixed; this is what stops the class from coming back.
7+
8+
The gate matches the three-part shape, and part 3 is what makes it a rule rather than noise — a read-only probe stays completely legal, and the cures are never flagged: a probe deferred into a lazy accessor or a `kernel:ready` hook, a probe whose ordering an ADR-0116 declaration (`dependencies` / `optionalDependencies` / `requiresServices`) has already made final, and a verdict drawn at a declared seal (`sealNodeTypeVocabulary()`) all pass.
9+
10+
Its reach is stated rather than implied: it under-matches on purpose. `getService('cache')` is visible, a `resolveCacheOrFallback()` three layers down another package is not, and #4769 is invisible to it entirely — that "registry" is the `sys_migration` table in a database. This stops the bleeding; it does not cure. Whether the kernel contract should be tightened further is #4776.
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
---
2+
"@objectstack/plugin-audit": minor
3+
---
4+
5+
feat(plugin-audit)!: retire `sys_comment.visibility` and `sys_comment.reply_count` (#4756, ADR-0049)
6+
7+
Both fields were modelled with **zero** runtime consumers — nothing in this repo,
8+
in `objectui`, or in `cloud` ever read or maintained either one. ADR-0049
9+
enforce-or-remove; maintainer decision: remove both. Same disposition, and for
10+
the same stated reason, as `sys_attachment.share_type` / `sys_attachment.visibility`
11+
in #2755 ("attachment access is derived from the parent record").
12+
13+
**REMOVED — `sys_comment.visibility`** (`'public' | 'internal' | 'private'`,
14+
defaulted `'public'`).
15+
16+
This one is a **security-looking key with no gate behind it**, which is the
17+
primary reason it goes rather than stays. No code path consulted it: not
18+
`enforceFeedsCapability`, not the record-level gates added in #4630, not the
19+
REST layer, not objectui's discussion panel. A comment an author marked
20+
`private` was visible to exactly the same people as a `public` one — an app
21+
author (or an AI authoring metadata) reading the field list would reasonably
22+
believe otherwise, and get a silent security failure instead of an error. That
23+
is the Prime Directive #10 trap in its textbook shape.
24+
25+
There is **no replacement key**: after #4630, who can see a comment is decided
26+
by the record-level permissions of the record its `thread_id` names — one
27+
coherent rule. A per-row enum layered on top would be a second source of truth
28+
for the same question. The enum's only genuinely missing meaning ("hidden from
29+
external/portal principals") depends on external principals existing at all,
30+
which waits on ADR-0090 D11's `externalSharingModel`; today there is nobody to
31+
hide a comment from. This does not foreclose that design — when portals land,
32+
a visibility key can return **enforce-first**, with a real gate and tests.
33+
34+
**FROM → TO:** stop sending `visibility` on `sys_comment` writes; to restrict
35+
who sees a discussion, restrict who can read the record `thread_id` points at.
36+
37+
**REMOVED — `sys_comment.reply_count`** (`number`, `defaultValue: 0`,
38+
`readonly: true`).
39+
40+
Never incremented anywhere, and `readonly` meant an author could not set it by
41+
hand either, so every row read `0` forever — a UI binding an "N replies" badge
42+
to it rendered `0` for every thread. Deliberately **not** replaced by an
43+
`afterInsert`/`afterDelete` roll-up: the predicate/bulk write-hook gaps tracked
44+
by #4770 / #4778 / #4779 (a hook that returns early without a single-record id
45+
lets the whole bulk operation through) are exactly where a hook-maintained
46+
counter drifts — a bulk delete of replies would never decrement it. A counter
47+
that drifts is worse than no counter, because both the UI and an AI reading the
48+
record trust it. If a badge needs the number, aggregate `parent_id` children at
49+
read time; a designed roll-up can be revisited once #4775's family has settled
50+
bulk-hook semantics.
51+
52+
**FROM → TO:** replace reads of `reply_count` with a count of `sys_comment` rows
53+
whose `parent_id` is the comment's id.
54+
55+
**Stored data.** Existing databases keep both columns as **unmanaged leftovers**
56+
— no migration, matching #2755. What changes where:
57+
58+
- **Reads are loud everywhere.** The read-axis gates (#4134 / #4226) resolve
59+
field names from the object schema, not from the table, so a filter, sort,
60+
`select` or `expand` naming `visibility` / `reply_count` now answers
61+
`400 INVALID_FIELD` on every deployment, leftover column or not. A "0 replies"
62+
badge that silently lied becomes an error that names itself.
63+
- **Writes are loud on new databases only.** A database provisioned after this
64+
change has no such column, so the write fails at the driver and is mapped to
65+
the same `400 INVALID_FIELD` envelope. On a pre-existing database the leftover
66+
column still accepts a value nothing will ever read — record validation does
67+
not reject undeclared keys. Dropping the two columns is an optional manual
68+
cleanup, not a requirement.

.github/workflows/lint.yml

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,24 @@ jobs:
204204
- name: Durability-degradation log-level guard
205205
run: pnpm check:durability-log-level
206206

207+
# Startup registry-verdict guard (#4777). One showcase cold start produced
208+
# three instances of one shape in three unrelated subsystems: ask a
209+
# registry "is X there?" while the boot is still filling it, treat the
210+
# "no" as final, and RECORD it — cached on the instance (#4772
211+
# plugin-auth), asserted in a warn (#4771 service-automation), or written
212+
# to the database (#4769 objectql). The provider registers a moment later
213+
# and nothing undoes the record. This gate walks the AST for that
214+
# three-part shape: a pre-ready registry read (init()/constructor, or an
215+
# ADR-0018 open capability registry before its seal) whose absent verdict
216+
# is recorded. Reading is legal and must stay legal — a probe licensed by
217+
# an ADR-0116 declaration, or one deferred into a closure/kernel:ready
218+
# hook, is exactly the fix and is never flagged. Deliberately narrow: it
219+
# cannot see a probe behind an indirection in another package, and it
220+
# cannot see #4769 at all (that "registry" is a database table). Runs its
221+
# own --self-test first.
222+
- name: Startup registry-verdict guard
223+
run: pnpm check:startup-registry-verdict
224+
207225
# Release-notes drift guard: the platform is one version-locked train, so
208226
# every released @objectstack/spec major must have a curated, navigable
209227
# release page at content/docs/releases/v<major>.mdx. Catches the gap that

0 commit comments

Comments
 (0)