Skip to content

Commit e6b3125

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-4391-crypto-hash-retire
# Conflicts: # docs/protocol-upgrade-guide.md # packages/spec/spec-changes.json # packages/spec/src/migrations/registry.ts
2 parents 0418943 + 29326f8 commit e6b3125

84 files changed

Lines changed: 7110 additions & 768 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: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
---
2+
"@objectstack/service-analytics": minor
3+
---
4+
5+
fix(service-analytics): a measure a query never reported reads 0 for a count/sum on every merge seam (#4708)
6+
7+
A dataset measure carrying its own `filter` runs as a separate grouped
8+
sub-query and is merged back onto the selected dimensions. A `GROUP BY` over a
9+
filtered row set emits **no group at all** for a dimension value the filter
10+
excludes entirely, so the measure comes back **absent**, not `0` — and
11+
`computeDerived` treats an absent operand as unknowable, so every ratio over it
12+
goes null too. The cell then renders blank, which is visually identical to "no
13+
data for this row" and means the opposite.
14+
15+
The bias runs the worst possible way: the rows that blank are the ones whose
16+
numerator matched nothing — the **worst-performing rows**. A `lead_source` that
17+
won nothing rendered as "no data" while one that won everything rendered fine.
18+
19+
The empty-group value is now filled **by aggregate kind** into every measure
20+
column the assembled grid lists but no query reported:
21+
22+
| aggregate | over an excluded group | why |
23+
|:---|:---|:---|
24+
| `count`, `count_distinct` | `0` | "how many rows matched" has an exact answer when the answer is none |
25+
| `sum` | `0` | the identity element of the empty set |
26+
| `avg`, `min`, `max` | stays `null` | genuinely undefined — there is nothing to average |
27+
28+
Filling all five with `0` would trade this lie for its mirror image, reporting a
29+
measurement nobody made, so the kinds are judged separately (via
30+
`emptyGroupValueFor`, shared with the authoring-side coherence checks).
31+
32+
**Only cells are filled, never rows.** A dimension value no query reported at
33+
all has genuinely no data and stays out of the grid.
34+
35+
**What changes beyond the measure-scoped seam.** The fill previously ran before
36+
the `compareTo` merge, and that merge *appends* a row for every bucket the
37+
PREVIOUS window had and this one does not. Every base measure on those rows —
38+
including unfiltered ones — was absent, so a lead source that sold last month
39+
and nothing this month rendered as "no data" instead of `0`: the same worst-row
40+
bias, one merge later. The fill now runs after every merge and covers all base
41+
measures plus their `<measure>__compare` columns.
42+
43+
Widgets that worked around this with `?? 0` in the consumer or a `coalesce` in
44+
the measure can drop it; the coercion belongs in the executor, which is the only
45+
layer that knows which aggregate produced the gap.
46+
47+
**New export.** `fillEmptyGroups(rows, columnAggregates)` is exported from the
48+
package root beside `mergeByDimensions`, so a host assembling a grid outside
49+
`DatasetExecutor` can apply the same aggregate-kind rule rather than
50+
reimplementing it — which is what makes this a `minor` rather than a `patch`.
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
---
2+
"@objectstack/plugin-approvals": patch
3+
---
4+
5+
fix(approvals): the record lock now holds for predicate (`multi`) updates (#4778)
6+
7+
The ADR-0019 record lock — "while a record has a pending `sys_approval_request`,
8+
block edits to it" — was enforced only for updates that reach the hook with an
9+
`input.id`. The engine extracts that id from a **scalar** `where.id` alone; an
10+
operator object (`{ $in: [...] }`) or any other predicate is a multi-row write
11+
that routes to `updateMany` and arrives with no id. The hook opened with
12+
`if (!id) return`, so it read *"no row was resolved"* as *"there is nothing to
13+
authorize"* when the truth was *"nothing was ever queried"*.
14+
15+
Rewriting the very same edit as `multi: true` therefore walked straight past the
16+
lock:
17+
18+
```ts
19+
// rec_1 carries a pending approval, lockRecord is not disabled
20+
await ql.update('crm_opportunity', { amount: 999 }, { where: { id: 'rec_1' } }); // RECORD_LOCKED
21+
await ql.update('crm_opportunity', { amount: 999 }, { where: { id: { $in: ['rec_1'] } }, multi: true }); // went through
22+
await ql.update('crm_opportunity', { amount: 999 }, { where: { name: 'x' }, multi: true }); // went through
23+
```
24+
25+
No privilege was needed for that bypass — not an `admin` role, not `isSystem`,
26+
not `lockRecord: false`, not a whitelisted `approvalStatusField`. Every caller
27+
shape that can spell a predicate (SDK, ObjectQL, a flow's `update_record`) could
28+
produce it. It is the same fail-open reasoning fixed for `sys_attachment`
29+
(#4757) and `sys_comment` (#4630), in the one place where it needed no
30+
privilege at all.
31+
32+
**The hook now resolves the rows a write touches before deciding.** By-id writes
33+
are unchanged (the driver writes by primary key, so the rest of `where` must not
34+
narrow the verdict). A predicate write is decided by intersecting the caller's
35+
predicate with the records that are actually locked — which is also what keeps
36+
it cheap: the query is bounded by the object's **pending approvals**, never by
37+
the update's match set, so a mass update of 50 000 unlocked rows costs one
38+
bookkeeping probe and is allowed. An unscoped `multi` update over the whole
39+
table reaches every locked row of the object and is refused while any is held.
40+
41+
**Fail-closed, both ways.** Past 1 000 locked records — the bound the attachment
42+
and comment guards use — or if the intersection query fails, the write is
43+
refused rather than allowed: the lock could not prove the write misses a locked
44+
row. The approvals bookkeeping being unreadable at all stays the one fail-open,
45+
as before: this hook is global over every object, so a kernel without
46+
`sys_approval_request` would otherwise refuse every update in the deployment.
47+
Both the bookkeeping and the match-set resolution are read under a **system**
48+
context — a guard's own input must never be narrowed by the caller's
49+
visibility, since a locked row you cannot read is still a row you may not write.
50+
51+
**Every exemption moved with the guard**, which is the other way this class of
52+
fix goes wrong — a guard extended to more rows that carries only its deny rules
53+
turns a fail-open into a false-positive. `isSystem`, the `admin` override, the
54+
`approvalStatusField` status mirror, `lockRecord: false` and the owning run's
55+
`flowRunId` (#3456 / #3712) all decide a predicate write exactly as they decide
56+
a by-id write, each pinned by tests on both predicate shapes. Refusals now name
57+
the record and object that are locked.
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
---
2+
---
3+
4+
chore(devx): `pnpm check:i18n` now FAILS on an undeclared authoring key, not just on bundle drift
5+
6+
Releases nothing — the change is confined to `scripts/check-i18n-bundles.mjs` and
7+
the root `check:i18n` script. No package source, no published behaviour, and
8+
deliberately **not** the `os i18n extract` exit code (that would write an internal
9+
hygiene rule into the public CLI contract).
10+
11+
#4736 cleaned nine `scripts/i18n-extract.config.ts` files that all opened their
12+
`defineStack({ … })` with the same undeclared `name:` key. Nine, because the same
13+
mistake was copied from the first one — and nothing stopped any of them. The
14+
#4167 unknown-authoring-key lint *saw* every single one: it printed
15+
`stack.name: 'name' is not a declared stack key, so its value is dropped at load`
16+
on stderr, once per package, on every run. But the CLI exited 0 and the gate only
17+
judged bundle drift, so those nine warnings appeared inside a **fully green**
18+
`check:i18n` and were read as noise nine times. A warning that nine authors
19+
filtered out is not a control; #4736 cleaned the symptom, this closes the hole.
20+
21+
**What changed.** The gate now reads the extractor's **stderr** — which it
22+
previously let flow straight through to the terminal, seen by nobody and judged by
23+
nothing — and fails on the unknown-authoring-key signature. Coverage needs no
24+
manifest: `findConfigs` walks `packages/`, so the tenth config is gated the day it
25+
lands.
26+
27+
**The two verdicts stay separate.** Bundle drift keeps its own section and its own
28+
remedy (`--write`); the new class gets its own, naming the package, the config
29+
path, the key, and the consequence that matters — *the value is dropped at load,
30+
so whatever it was meant to configure is not in effect and never was*.
31+
Regenerating bundles does not fix it, and the message says so.
32+
33+
**The gate is proven able to go red**, not merely observed green — the failure
34+
mode of `check:react-declaration-parity` (#4690), which exited 0 with nothing to
35+
check. Two proofs: `node scripts/check-i18n-bundles.mjs --self-test` (now wired
36+
into `check:i18n`, ahead of the real run) drives both classifiers over recorded
37+
CLI output, including a case asserting neither verdict matches the other's output;
38+
and the gate was run against the nine configs restored from `ffab8033b^`, the
39+
commit before #4803 deleted the keys, where it reports all nine and exits 1.
40+
41+
Fixing an offending config means deleting the key at the producer. If a key is
42+
genuinely wanted it gets declared in `packages/spec` deliberately — not
43+
accommodated by a consumer-side fallback, and not silenced by making
44+
`ObjectStackDefinitionSchema` strict (which would mute the lint itself; see
45+
`metadata-authoring-lint.ts`).
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: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
---
2+
"@objectstack/types": minor
3+
"@objectstack/verify": minor
4+
"@objectstack/cli": patch
5+
---
6+
7+
fix(verify): resolve the enterprise organizations package from the HOST APP (#4700)
8+
9+
`bootStack(app, { multiTenant: true })` — and therefore `objectstack verify
10+
--multi-tenant` — could never load `@objectstack/organizations`. Node ESM
11+
resolves a bare `import()` against the **importer's own realpath**, which for
12+
`packages/verify` is inside the framework workspace, while the enterprise
13+
package is cloud-private and only ever lives in the verified app's
14+
`node_modules`. Every real host app fell into the catch and was told to
15+
"Install/link it in this workspace" — about a package it had already installed.
16+
Same defect class as cloud#1013, which fixed `objectstack serve`; #4699 fixed
17+
that one call site and this issue tracked the two the sweep left behind.
18+
19+
**New: `@objectstack/types/node`.** The host-app resolver (`createHostRequire` /
20+
`createHostImporter`) moved out of `packages/cli/src/utils/import-from-host.ts`
21+
— where `@objectstack/verify` and the dogfood suite could not import it without
22+
inverting the dependency direction — into a **node-only subpath export** of
23+
`@objectstack/types`. One behaviour, one source; the CLI now consumes it and its
24+
private copy is deleted.
25+
26+
It is a subpath and **not** the root export because `@objectstack/types` is a
27+
dependency of `@objectstack/hono` ("edge-compatible REST API server for
28+
Cloudflare Workers, Deno, Bun, and Node") and of the plugin layer a `LiteKernel`
29+
boots on Workers. The root entry reaches zero `node:` builtins, and a Workers
30+
bundle breaks on `node:module` even when nothing calls it. `tsup` emits the two
31+
entries as separate self-contained bundles (`splitting: false`), and a test
32+
walks the root's import graph and fails on the first reachable `node:`
33+
specifier, so the isolation is enforced rather than merely intended. Same
34+
arrangement `@objectstack/metadata` already ships for its `./node` subpath.
35+
36+
**New: `BootOptions.hostRoot`** (optional, defaults to `process.cwd()`) names
37+
the app whose `node_modules` supplies those optional packages — for a harness
38+
booting an app that is not the working directory.
39+
40+
**The dogfood multi-org gates had never run.** Two suites probed availability
41+
with the same bare `import()` and so were **constant-false** — not "false
42+
because absent" but false by construction, in every environment including the
43+
cloud CI whose comment claimed it ran them. The #1994 cross-tenant RLS proof and
44+
the attachments cross-tenant isolation block had therefore never executed while
45+
the suite reported green (Prime Directive #10, test-suite edition). They now
46+
resolve like the runtime does, and `OS_TEST_MULTI_ORG_ENABLED=1` declares that a
47+
run is expected to ship the package — turning a silent skip into a loud failure,
48+
so a run can no longer pass by quietly not running the gates it exists for.
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: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
---
2+
---
3+
4+
Tooling-only (#4731): the `@objectstack/console` changeset that `scripts/bump-objectui.sh`
5+
writes on an objectui pin bump is now derived from what objectui **declared** — the
6+
`.changeset/*.md` files added over the pinned range — instead of guessed from
7+
conventional-commit types on the subject line. Releases nothing; no package changes.
8+
9+
The guess had three measured failure modes on one real range (`7d9734d5e321..785b8a5d432c`,
10+
53 non-merge commits): `grep -iE '^- (feat|fix)'` dropped **all 13** releasing commits that
11+
were not `feat`/`fix` — every breaking `refactor(...)!` among them, including
12+
`refactor(layout)!: delete PageNodeRenderer` and the burn-ledger batches (objectui#3220 /
13+
objectui#3224) — while pulling in **5** commits that release nothing at all (two of them
14+
`fix(ci)`); `head -40` truncated the list in silence at 34/40 used; and the bump level came
15+
from `grep -ciE '^feat'`, so a range of nothing but breaking refactors stamped `patch`.
16+
17+
`scripts/objectui-changeset-digest.mjs` now answers the question the script actually needs
18+
answered — *does this commit ship in the frontend release?* — from objectui's own
19+
declaration: a `.changeset/*.md` with package names releases, an empty frontmatter block is
20+
changesets' own "release-nothing", and the declared `major`/`minor`/`patch` **is** the bump
21+
(no `^feat` inference left anywhere). Nothing is capped by default; a cap that does fire
22+
names the real remainder, the release-nothing changesets and changeset-less commits are
23+
counted in the body rather than dropped in silence, and an unwalkable range (shallow clone,
24+
initial pin) emits a list explicitly labelled degraded. Guarded by
25+
`pnpm check:objectui-changeset` (`--self-test`, wired into `Lint & Type Check`).

0 commit comments

Comments
 (0)