Skip to content

Commit ffc1884

Browse files
committed
merge main into #4001 remeasure (pick up #4856 60s timeout)
2 parents b78b7f2 + 2f05139 commit ffc1884

128 files changed

Lines changed: 8385 additions & 1053 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: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
---
2+
"@objectstack/service-analytics": patch
3+
---
4+
5+
fix(service-analytics): `compareTo` applies measure-scoped filters, so `<measure>__compare` is the same measure as the column beside it (#4820)
6+
7+
A dataset measure declared with its own `filter` is scoped by running a
8+
supplementary grouped sub-query — `combineFilters(baseFilter, measureFilters[m])`
9+
— and merging it back by dimension key. The `compareTo` pass did not: it issued
10+
**one** shifted query over every base measure with only the base filter as its
11+
`where`, and never consulted `compiled.measureFilters` at all.
12+
13+
For a dataset like
14+
15+
```ts
16+
measures: [
17+
{ name: 'revenue', aggregate: 'sum', field: 'amount' },
18+
{ name: 'won_count', aggregate: 'count', filter: { stage: 'closed_won' } },
19+
]
20+
```
21+
22+
the current-period column was scoped and the comparison column was not — two
23+
different measures rendered side by side under one label:
24+
25+
| # | measures | where | |
26+
|:---|:---|:---|:---|
27+
| 1 | `revenue` || current |
28+
| 2 | `won_count` | `{"stage":"closed_won"}` | current |
29+
| 3 | `revenue`, `won_count` | **absent** | shifted |
30+
31+
`won_count__compare` was therefore a count of **every** opportunity in the
32+
previous window, inflated by exactly the rows the measure exists to exclude.
33+
The error runs one way: the comparison period always looks better, so a "won
34+
deals vs. last month" tile reads as a collapse when nothing went wrong. Only
35+
filter-scoped measures were affected — the unfiltered ones next to them compared
36+
correctly, which is what made it survive.
37+
38+
The comparison window now runs the **same pass** as the current period —
39+
unfiltered measures in one shifted query plus one shifted sub-query per
40+
filter-scoped measure, merged by dimension key — through a single shared
41+
implementation, so the two paths cannot re-diverge at the next change. The
42+
dataset filter, the presentation's `runtimeFilter` and the measure's own filter
43+
compose identically in both windows; the only difference between them is the
44+
shifted `dateRange`.
45+
46+
Numbers reported by existing dashboards change where a filtered measure was
47+
compared: with 3 won deals this month against 1 won of 5 opportunities last
48+
month, `won_count__compare` was `5` and is now `1`.
49+
50+
Cost: one extra query per filter-scoped measure when `compareTo` is set.
51+
Selections whose measures carry no filter are untouched and still compare in a
52+
single shifted query.
53+
54+
The empty-group fill (#4708) covers the new seam: a group the measure's filter
55+
empties in the *previous* window now reports `0` for a `count`/`sum` compare
56+
column rather than blanking it, exactly as it already did for the current period.
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: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
---
2+
"@objectstack/plugin-auth": patch
3+
---
4+
5+
fix(plugin-auth): OTP 冷却按声明值真正生效 —— 发送历史的保留时长不再被硬编码的 1 小时截断 (#4808)
6+
7+
`OtpSendGuard`**两个**维度:每号码「距上次发送至少 N 秒」的冷却(`cooldownSeconds`),
8+
和每号码「滚动一小时内至多 M 条」的上限(`maxPerHour`)。它们需要**不同**的时间窗,而此前
9+
两者共用了同一个硬编码的一小时:发送历史按 1 小时剪枝、也按 1 小时写 TTL。
10+
11+
于是把 `phoneOtp.cooldownSeconds` 配成**大于 3600** 时:配置被接受,没有校验错误,没有 warn,
12+
但冷却所依据的那条历史记录在 1 小时处就被丢掉了 —— 声明「两次发送间隔 2 小时」,实际最多
13+
只有 1 小时,**反滥用强度是声明值的一半,而且没有任何信号**(ADR-0049 声明 ≠ 强制)。
14+
计价单位仍然是真金白银的短信。这与 #4790 是同一个 guard 上的**不同**缺陷,且改动前后行为
15+
一致 —— 不是 #4806 引入的。
16+
17+
**修法(issue 的方向 1):保留时长跟随配置。** 历史保留 `max(1 小时, cooldownSeconds)`,
18+
即「两个维度里还用得着它的那个更长的窗」;TTL 同步跟随,记录因此活得比它所度量的冷却更久。
19+
每小时上限仍在**它自己的滚动一小时**内计数,所以超长冷却不会反过来把 `maxPerHour` 收得比
20+
声明的更严。
21+
22+
**上限是拒绝,不是又一次截断。** `cooldownSeconds` 超过 `MAX_COOLDOWN_SECONDS`(86400,
23+
即 24 小时)会在**启动时**抛错(`AuthPlugin.init()` 构造 `AuthManager` 处),错误信息给出
24+
值、上限和改法。把截断点挪到更高的数字只是把同一个缺陷往外推一个量级;设上限的理由是:
25+
一条号码的历史会在共享缓存里驻留整个冷却期,而超过一天的封锁已经不是发送节流而是账号锁定
26+
(另一套机制、另一套管控)。这条边界同时把「`cooldownSeconds` 误填成毫秒」这类笔误变成
27+
一次响亮的拒绝(5 分钟以上的意图都会被挡下)。校验放在**配置处**而不是首次发送处:guard
28+
是惰性构造的,只在那里校验的话,一个配置错误会表现为 `/phone-number/send-otp` 的 500。
29+
30+
**默认配置行为完全未变**,并有测试锁定:未配置 `phoneOtp` 时仍是 60 秒冷却 + 每小时 5 条,
31+
历史保留与 TTL 仍是 3600 秒。
32+
33+
对使用者的影响:
34+
35+
- `phoneOtp.cooldownSeconds` 现在在 1 小时以上也真正生效(上限 24 小时);
36+
- 超过 24 小时、负数或非有限值的配置**开始被拒绝**——这些值此前从未按声明工作过(要么被
37+
静默截断到 1 小时,要么被静默钳成 0 即关闭冷却),因此不存在依赖其旧行为的部署;
38+
- 新增导出:常量 `MAX_COOLDOWN_SECONDS` 与校验函数 `assertOtpCooldownSeconds()`
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
---
2+
"@objectstack/spec": minor
3+
---
4+
5+
feat(spec): register `ROLLED_BACK` / `NOT_ATTEMPTED` batch-row error codes; record the batch-row shape migration (#4793)
6+
7+
Support for the `@objectstack/metadata-protocol` v17 batch-row migration
8+
(#4793 — see its major changeset for the wire change itself):
9+
10+
- `ERROR_CODE_LEDGER` registers two codes under `@objectstack/metadata-protocol`:
11+
`ROLLED_BACK` (atomic data-batch row was written, then undone by the batch
12+
rollback) and `NOT_ATTEMPTED` (row never ran — an earlier row's failure
13+
aborted the batch). They are the structured, `ApiError.code`-level form of
14+
the message-string prefixes #4620 introduced; `ApiErrorSchema.code` now
15+
accepts them and clients branch on the code instead of regexing messages.
16+
- The ADR-0087 migration registry gains the protocol-17 semantic entry
17+
`batch-row-result-schema-shape` (a RESPONSE surface — nothing stored to
18+
rewrite, so it is a documented TODO for readers of the legacy `row.error` /
19+
`row.record` keys), and `docs/protocol-upgrade-guide.md` is regenerated
20+
with it.
21+
- `BatchOptionsSchema.atomic` / `BatchOperationResultSchema.errors` describe
22+
strings now document the code-based rollback marking (reference docs
23+
regenerated).
24+
25+
No schema *shape* changes: `BatchOperationResultSchema` already declared
26+
`errors` / `data` / `index` — the runtime caught up to it.
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
---
2+
"@objectstack/metadata-protocol": major
3+
---
4+
5+
fix(metadata-protocol)!: batch per-row results now deliver the declared `BatchOperationResultSchema` shape (#4793)
6+
7+
**Breaking wire change** on the per-row `results` entries of the three
8+
bulk-write endpoints — `POST /data/:object/batch`, `/updateMany`,
9+
`/deleteMany`. The rows had drifted from the schema that declares them:
10+
`BatchOperationResultSchema`, the client SDK's exported `BatchOperationResult`
11+
type and the reference docs all said `errors: ApiError[]` / `data` / `index`,
12+
while the wire carried `error: string` / `record` and never sent `index`. A
13+
TypeScript consumer written against the published type compiled, validated,
14+
and read `undefined` at runtime. The wire now delivers exactly what is
15+
declared (a conformance pin parses every emitted row against the schema, so
16+
the two cannot silently fork again).
17+
18+
**FROM → TO, per row:**
19+
20+
| Before (legacy wire) | After (declared schema) | Your fix |
21+
| --- | --- | --- |
22+
| `row.error` (string) | `row.errors` (`ApiError[]`) | read `row.errors?.[0]?.message`; branch on `row.errors?.[0]?.code` |
23+
| `row.record` | `row.data` | rename the read |
24+
| — (never sent) | `row.index` (number) | new — the row's position in the request array; use it to correlate failure rows that carry no `id` |
25+
| `row.droppedFields` | `row.droppedFields` | unchanged |
26+
27+
**Rollback marking is structured now.** The `ROLLED_BACK:` /
28+
`NOT_ATTEMPTED:` message-string prefixes that #4620 introduced (see the
29+
`many-data-atomic-real-or-refused` changeset — its description of those
30+
markers is superseded by this entry) are promoted to first-class
31+
`ApiError.code` values, registered in the spec's ERROR_CODE_LEDGER:
32+
33+
- `errors[0].code === 'ROLLED_BACK'` — the row was written, then undone by the
34+
atomic batch rollback; `message` carries the causal row's index and error.
35+
- `errors[0].code === 'NOT_ATTEMPTED'` — the row never ran; an earlier row's
36+
failure aborted the batch.
37+
- the causal row keeps its own error code (e.g. `RECORD_NOT_FOUND`,
38+
`VALIDATION_FAILED`; an unclassified engine throw maps to `INTERNAL_ERROR`,
39+
with `httpStatus` mirrored when the error carried one).
40+
41+
Branch on the code — do **not** regex message prefixes; the prefixes are gone.
42+
43+
**Who is affected:** only readers of the *legacy* keys — which were never in
44+
the schema or the SDK types, so they were reachable only via `as any` or bare
45+
JS. Code written against `BatchOperationResult` (the published contract) needed
46+
this change to start working and needs no migration. There is no
47+
dual-emission or compatibility fallback: this is a hard cut inside the v17
48+
major window, and the old keys simply no longer exist on the wire.

.changeset/book-job-translation-app-authorwarn-keys-retired.md

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,8 +39,14 @@ Run `os migrate meta --from 16` to rewrite existing sources automatically.
3939
migration table steered retired `errors:` authors straight into it. **That
4040
guidance entry is rewritten here**: retiring one dead key by pointing at
4141
another is the defect, not the fix.
42-
- **`app.homePageId`***its own hedge*. "If not set, usually defaults to the
43-
first navigation item" described the only behaviour there was.
42+
- **`app.homePageId`***a second source for one fact*. Not unread: objectui's
43+
console consumed it in `resolveLandingRoute()` and it was the only thing
44+
deciding where an app opened. (This entry first shipped saying otherwise;
45+
corrected in #4709, which upheld the removal.) What condemns the key is its
46+
shape — an ID cross-reference into `navigation` with no referential integrity,
47+
falling back to the first item *silently* when the id dangled. If "land
48+
somewhere other than first" is ever wanted again it belongs on the navigation
49+
item itself, not on a pointer that can miss.
4450
- **`app.areas[].order`***the sibling that works*. Nav-item `order` really is
4551
sorted; area-level order never was, and both renderers iterate the array as
4652
authored.
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: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
---
2+
---
3+
4+
CI-only (#4859): Test Core / Dogfood 各 2→3 分片 + merge-queue 失败自动分诊评论 workflow。仅 `.github/workflows/**`,不发布任何包。

0 commit comments

Comments
 (0)