Skip to content

Commit 40b7cd4

Browse files
committed
Merge origin/main into claude/issue-16609-window-functions-presentation
2 parents 8981967 + ed7243d commit 40b7cd4

154 files changed

Lines changed: 11893 additions & 986 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: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
---
2+
"@objectstack/spec": minor
3+
---
4+
5+
feat(spec): `AGGREGATE_FIELD_TYPE_COMPATIBILITY` accepts `boolean` / `toggle` for `sum` / `avg` / `min` / `max` — ruling #11152 (booleans aggregate as numbers on every backend) stands over batch #59's blanket default (#16685)
6+
7+
The aggregate × field-type table declared by `@objectstack/spec/data` gains the boolean class (`boolean`, `toggle`) on its four arithmetic / order rows. Two maintainer rulings collided on that class: decision batch #59 (2026-09-06) said "every other pair: refused" without ever naming booleans, while ruling #11152 (2026-08-28) pins that booleans aggregate as NUMBERS on every backend with no per-aggregate exception — `sum(flag)=3`, `avg(flag)=0.5`, `min(flag)=0`, `max(flag)=1`, enrolled on six backends by the spec's own `AGGREGATION_CASES`, and implemented by `driver-sql`'s Postgres cast (#11635). The director ruling of decision batch #80 (2026-09-08, #16685, maintainer verbatim 「其他同意」, option A) holds that the specific ruling stands over the blanket default: the four rows carry both boolean members, and nothing else moves — `AGGREGATION_CASES` and the driver cast are untouched.
8+
9+
| Aggregate | Accepted field types |
10+
|---|---|
11+
| `count`, `count_distinct` | every `FieldType` |
12+
| `sum` | `number`, `currency`, `rating`, `slider`, `progress`, `summary`, **`boolean`, `toggle`** |
13+
| `avg` | the numeric class (`percent` included), **`boolean`, `toggle`** |
14+
| `min`, `max` | the numeric class, `date`, `datetime`, `time`, **`boolean`, `toggle`** |
15+
| every other pair | refused |
16+
17+
Why it matters: `avg(flag)` is the win-rate / SLA-violation-rate shape (#11065) — the reason the conformance table exists. A compatibility table refusing it would refuse a pair every backend is REQUIRED to answer, and the two consumer legs that execute this table (the compile-time refusal in the dataset compiler, #16099, and the authoring-time lint rule) would have turned a supported measure into an authoring error.
18+
19+
**Additive, `minor`.** No export is added, removed or renamed — the exported declarations are unchanged (the table's declared type is the same; only its value gains members), the new `BOOLEAN_AGGREGATE_FIELD_TYPES` constant is module-private — absent from `dist/*.d.ts` and from the bundles' export lists — and `api-surface/` and `export-origins/` are untouched — and the accept set only WIDENS: every pair accepted before is still accepted. It rides the same release as the table's own changeset, so the version outcome is the same either way; `minor` is declared because widening a published accept set is the same class of change the table's introduction was (decision batch #35 puts additive widening at `minor`), and because the two consumer legs are the change's real audience: a measure over a boolean field compiles and lints clean.
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
---
2+
"@objectstack/spec": minor
3+
---
4+
5+
feat(spec): declare the aggregate × field-type compatibility matrix a dataset measure is judged against — `AGGREGATE_FIELD_TYPE_COMPATIBILITY` and `isAggregateCompatibleWithFieldType` (#16353, spec half of #16099)
6+
7+
A dataset measure pairs an `aggregate` with a `field`, and nothing between author and driver correlated the two: `avg` over a `Field.datetime` compiled to `AVG(col)` and reached the backend, where one SQL family averages the column's storage form and another rejects the call — one metadata document, two answers. Which pairs are accepted is a contract, so it is now declared once in `@objectstack/spec/data`:
8+
9+
| Aggregate | Accepted field types |
10+
|---|---|
11+
| `count`, `count_distinct` | every `FieldType` |
12+
| `sum` | `number`, `currency`, `rating`, `slider`, `progress`, `summary` — the numeric class EXCEPT `percent` (a rate does not add; `isIncoherentAggregate` already says so) |
13+
| `avg` | the numeric class, `percent` included |
14+
| `min`, `max` | the numeric class plus `date`, `datetime`, `time` — both return a value of the field's own type |
15+
| every other pair | refused |
16+
17+
The ruling (director, decision batch #59, 2026-09-06) named its buckets by category; the table resolves them against the real `FieldType` membership through the `field-value.zod` semantic classes: "numeric" is `NUMERIC_VALUE_TYPES` (`integer` is a driver-internal column alias, not a `FieldType` — the integer-valued authorable members are `rating` / `slider` / `progress`); "temporal" is the three temporal classes, `time` included because its stored form is a dialect question exactly like `date` / `datetime` (native TIME on Postgres and MySQL, canonical `HH:MM:SS[.fff]` TEXT on SQLite), the canonical form orders chronologically on every dialect, and `AnalyticsResult.fields[].type` already describes `min` / `max` over it as temporal (#15768). `formula` is refused for arithmetic aggregates whatever its declared `returnType`: it is virtual in SQL storage, no column exists to aggregate.
18+
19+
**Booleans** (`boolean`, `toggle`) are NOT in the "every other pair" default: maintainer ruling #11152 has every backend answer them as numbers (`sum(flag)=3`, `avg(flag)=0.5`, `min(flag)=0`, `max(flag)=1`, pinned in the spec's `AGGREGATION_CASES`; `driver-sql` casts the aggregand on Postgres to make it hold), and the director ruling of decision batch #80 (2026-09-08, #16685) holds that specific ruling over batch #59's blanket default — the four arithmetic / order rows carry both boolean members (see that changeset). One refused row overrides an existing opinion and is recorded as such, not presented as agreement: **the string classes** are refused for `min` / `max` here, while `service-analytics` (#15768) already types `min` / `max` over them as a supported `'string'` result; the refusal is defensible (string order is collation-dependent) but it overrides that opinion.
20+
21+
**The narrowing, stated plainly.** Every pair outside the table — `avg` × `datetime`, `sum` × `text`, `min` × `json`, `sum` × `percent`, and so on — is an authoring shape `DatasetMeasureSchema` accepts today and will be REFUSED once the two consumer legs land: the compile-time refusal in the dataset compiler (#16099) and the authoring-time lint rule (its devx sub-card). A measure whose pair is refused is fixed by changing the aggregate to one the field's type supports (`min` / `max` for a temporal field; `avg` for a `percent`; `count` for anything), never by widening the table.
22+
23+
**Not breaking in this release, `minor` on purpose.** This changeset ships a table and a predicate that nothing yet enforces: `DatasetMeasureSchema` accepts byte-for-byte what it accepted before, no export is removed or narrowed, and no runtime path reads the table yet. It is an additive widening of the published surface — two new exports in `dist/*.d.ts` — which the maintainer ruling of 2026-09-04 (decision batch #35) puts at `minor`. The refusal itself arrives with the consumer legs, whose changesets carry the breaking declaration, its migration prescription and the ADR-0087 disposition; this one names the narrowing so an upgrading author can read the contract before it is executed.
24+
25+
`isIncoherentAggregate` is unchanged and stays the semantic opinion beside this table. The two diverge on exactly one pair: `count_distinct` × `percent` is flagged there and accepted here (the ruling reads `count_distinct` as "any type"). That divergence is pinned in the table's test and reported on #16353 rather than resolved silently.
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
"@objectstack/driver-sql": minor
3+
---
4+
5+
The record read doors present the builtin audit stamps (`created_at`, `updated_at`) and every declared `Field.datetime` column as the canonical instant text `YYYY-MM-DDTHH:MM:SS.sssZ` on EVERY dialect — Postgres and MySQL now included, exactly as SQLite always has (ADR-0053 addendum D-F1..D-F3, #13973).
6+
7+
**Consumer-visible change, Postgres and MySQL only.** An in-process consumer reading such a column off a `find()` / `findOne()` row, off the row `create()` / `update()` / `upsert()` / `bulkCreate()` / `bulkUpdate()` return, or out of `aggregate()` (`min` / `max`, a raw temporal group key) or `distinct()`, receives a `string` where it received a JS `Date`. The wire is unchanged: `JSON.stringify` already serialised that `Date` as the same ISO text, so REST, MCP and SDK callers see nothing move. A consumer that called a `Date` method directly on the field (`.getTime()`, `.toISOString()`, `.getFullYear()`) now fails loudly with a `TypeError` instead of silently working on one dialect; the sweep behind this change found none in the repository's non-test sources. A consumer that compared, sorted, keyed or formatted the value as text — the shape eight production-driver defects had (#13382, #13993#13999) — is now correct by construction on every dialect.
8+
9+
- **Where the fold happens.** At the driver's own read boundary (`formatOutput` for rows, `presentReadValue` for the aggregate/distinct doors). The `pg` and `mysql2` client parsers are untouched: a `Date` is still what the client materialises, and a raw knex read still hands it back. Only the driver's read doors changed.
10+
- **The builtin audit columns gain an `aggregate()` / `distinct()` arm on every dialect.** `max(updated_at)` and `distinct('created_at')` had no read presentation at all before — on SQLite they even missed ADR-0074's legacy-row repair — and now present exactly what `find()` presents.
11+
- **An Invalid `Date` is the one shape the fold hands through unchanged** (#14078: a MySQL zero `DATETIME`; a Postgres year past 275760). It has no canonical text; the fold never throws on it, and the consumer-side guards #14078 landed absorb it as before.
12+
13+
The per-site canonicalisations landed for #13993#13999 and #14078 stay correct and become no-ops on driver rows; nothing is removed here.

.changeset/duration-unit-in-key-name.md

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
"@objectstack/spec": minor
33
---
44

5-
feat(spec)!: a duration-shaped `z.number()` key carries its unit in the key name — `hook.timeout` / `job.timeout` / `DriverOptions.timeout``timeoutMs`, `MetadataManagerConfig.cache.ttl``ttlSeconds`, `cache.databaseLoader.ttl` `ttlMs`, tenant `idleTimeout` / `sessionTimeout``*Seconds`; new gate `check:duration-unit-keys` (#14478, #14519)
5+
feat(spec)!: a duration-shaped `z.number()` key carries its unit in the key name — `hook.timeout` / `job.timeout` / `DriverOptions.timeout``timeoutMs`, `MetadataManagerConfig.cache.databaseLoader.ttl``ttlMs` (the outer `cache.ttl` leaves outright under #15624 — nothing read it), tenant `idleTimeout` / `sessionTimeout``*Seconds`; new gate `check:duration-unit-keys` (#14478, #14519)
66

77
<!-- adr-0087: registered hook-timeout-to-timeout-ms, job-timeout-to-timeout-ms, metadata-manager-config-cache-ttl-unit-in-key, driver-options-timeout-to-timeout-ms, tenant-timeouts-unit-in-key -->
88

@@ -38,7 +38,7 @@ comment and published a bare `300` / `3600` to the reference page (#14519).
3838
| `HookSchema` (`hooks[]`) | `timeout` | `timeoutMs` | unchanged (ms) |
3939
| `JobSchema` (`jobs[]`) | `timeout` | `timeoutMs` | unchanged (ms) |
4040
| `DriverOptionsSchema` | `timeout` | `timeoutMs` | unchanged (ms) |
41-
| `MetadataManagerConfigSchema` | `cache.ttl` | `cache.ttlSeconds` | unchanged (s, default 3600) |
41+
| `MetadataManagerConfigSchema` | `cache.ttl` | *(deleted — its respelling `ttlSeconds` was retired before it shipped, #15624; the outer `cache` block was read by nothing, and the live TTL is `cache.databaseLoader.ttlMs`)* | |
4242
| `MetadataManagerConfigSchema` | `cache.databaseLoader.ttl` | `cache.databaseLoader.ttlMs` | unchanged (ms, default 60000) |
4343
| `DatabaseLevelIsolationStrategySchema` | `connectionPool.idleTimeout` | `connectionPool.idleTimeoutSeconds` | unchanged (s, default 300) |
4444
| `TenantSecurityPolicySchema` | `accessControl.sessionTimeout` | `accessControl.sessionTimeoutSeconds` | unchanged (s, default 3600) |
@@ -52,10 +52,13 @@ new MetadataManager({ cache: { ttl: 3600, databaseLoader: { ttl: 60_000 } } });
5252
// after — rename the key; the number is unchanged
5353
defineHook({ name: 'audit_order', object: 'order', events: ['afterInsert'], handler: 'auditOrder', timeoutMs: 5000 });
5454
defineJob({ name: 'nightly_sweep', schedule: { type: 'cron', expression: '0 1 * * *' }, handler: 'sweep', timeoutMs: 300000 });
55-
new MetadataManager({ cache: { ttlSeconds: 3600, databaseLoader: { ttlMs: 60_000 } } });
55+
new MetadataManager({ cache: { databaseLoader: { ttlMs: 60_000 } } }); // the outer `ttl` is deleted, not renamed (#15624)
5656
```
5757

58-
**Migration.** Rename each key; no value changes. Authoring an old spelling
58+
**Migration.** Rename each key; no value changes — with one exception: the outer
59+
`MetadataManagerConfig.cache.ttl` is DELETED, not renamed (its respelling `ttlSeconds`
60+
was retired before it shipped, #15624; nothing ever read the outer `cache` block, and
61+
the nested `cache.databaseLoader.ttl → ttlMs` rename above is unchanged). Authoring an old spelling
5962
fails to compile (`tsc`: the input type is `never`) and fails to parse with a
6063
prescription naming the new key. For `hooks[]` / `jobs[]` the rename is a
6164
mechanical D2 conversion (`hook-timeout-to-timeout-ms`,
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
---
2+
'@objectstack/spec': minor
3+
---
4+
5+
feat(spec): `ExecutionStepMetrics` gains an optional `failures` slot, and `FlowRunSummary.failed` is declared as the fold INCLUDING what a delegating node rolled up from its child (maintainer ruling 2026-09-06 on #15617, spec half)
6+
7+
Additive. Nothing an author writes is renamed, retired or narrowed; no accept
8+
set shrinks. One optional key is declared on a runtime-produced schema and the
9+
prose of a published contract is reconciled with itself.
10+
11+
**What was wrong.** `FlowRunSummary` said two things about `failed`. Its
12+
header paragraph declared that a `subflow` node rolls its child run's totals
13+
up into the parent — "this summary answers *what did this run cause*" — while
14+
the field itself declared `failed = Σ nodes[].failures`, a fold over the
15+
parent's own node executions. For a parent that delegates its rows to a
16+
`subflow` (or a `map` item) those give different answers, and the engine could
17+
only satisfy the second one: `ExecutionStepMetrics` carried `selected` /
18+
`acted` / `unmeasuredEffect` and no failure slot, so a child's contained
19+
failures had no path into the parent's fold. Measured on the real engine by
20+
the services seat (#15617): parent `loop { subflow(child) }` → parent
21+
`failed=0` while the five child summaries carried `failed=[0,0,0,0,1]`
22+
`acted` rolled up, `failed` did not.
23+
24+
**What this declares.**
25+
26+
- `ExecutionStepMetrics.failures` (optional, integer ≥ 0): node executions
27+
that failed inside a child run this execution delegated to and went on from
28+
— a `subflow` child or a `map` item whose run COMPLETED while containing
29+
failures, i.e. the child's `summary.failed`, rolled up. It folds into the
30+
delegating node's `nodes[].failures` and so into the run-level `failed`
31+
the same fold shape `acted` has, but not the same rule at the failed-child
32+
boundary (next bullet). Absent means the step delegated nothing, or its
33+
child tracked no count, or the producer did not track it (every step the
34+
engine emits between this release and the engine half) — never zero.
35+
- It is NOT the step's own outcome. A step that failed is `status: 'failure'`
36+
and counts once through `nodes[].failures`, as before; a child that FAILED
37+
— whether or not it also contained failures before it failed — is
38+
precisely that step failure: its own `failed`, contained and fatal alike,
39+
stays on the child's run row and nothing rides up, so one failure is never
40+
counted twice. This is where the rule parts from `acted`, which does carry
41+
a failed child's writes up to the parent. The control the card measured (a
42+
failing child → parent `failed=1`) keeps counting exactly as today.
43+
- `FlowRunSummary.failed` is declared, at the field, as the fold of
44+
`nodes[].failures` INCLUDING what a delegating node rolled up; the
45+
`FlowRunNodeSummary.failures` describe names the roll-up path, and its
46+
`status` describe states that a delegating node whose child contained
47+
failures reads `success` beside `failures > 0` — status is judged on the
48+
node's own executions.
49+
50+
**What this does not do yet.** This is the contract half of a two-lane
51+
landing (contract first). No producer populates `failures` in this release:
52+
`subflow-node.ts` and the `map` node roll the child's contained failures into
53+
the slot in the services half, #16314, and only then does a parent's
54+
`failed` start counting them. Until that lands, every `ExecutionStepMetrics`
55+
the engine emits is byte-identical to today's, `failed` is numerically what it
56+
was, and the flow-run reference page keeps the narrowed wording PR #15609
57+
shipped ("node executions **of this run**") on purpose — it is widened when
58+
both halves are in.
59+
60+
**Consumers.** A reader of `ExecutionStepMetrics` sees one more optional
61+
number and nothing else changes shape; a consumer that already sums
62+
`nodes[].failures` to cross-check `failed` keeps agreeing with it, because the
63+
fold is unchanged — the roll-up enters the per-node array, not beside it. Two
64+
consequences of that placement are part of the contract from this release,
65+
even though no producer populates the slot yet: on a delegating node
66+
`nodes[].failures` may exceed `runs` (`runs: 5, failures: 15` is a legal
67+
shape — five subflow executions whose children each contained three), and it
68+
is no longer only that node's own failed executions, so a reader that derived
69+
"this node's executions that failed" or a failure RATE from `failures / runs`
70+
must read a delegating node's number as "failures this node caused, its
71+
child's contained ones included".
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
"@objectstack/lint": minor
3+
---
4+
5+
New gating rule `flow-filter-token-unknown`: a `{…}` filter token in a flow node's `config.filter` that NEITHER `{…}` dialect can resolve is now an authoring-time `error`.
6+
7+
`filter-token-unknown` walks seven presentation collections and not `flows`, so `{TOMORROW()}` in a list view's filter failed the build while the identical string in a flow node's `config.filter` was silent — even though this package's other filter rules (`empty-combinator`, the preset-comparand rules) have reached flows all along.
8+
9+
The gap was not an oversight to close by adding a root. A flow node's filter is interpolated by the automation template evaluator **before** ObjectQL sees it, and only what that evaluator cannot resolve is handed on. Judging a flow filter against the ObjectQL vocabulary — the obvious one-line fix — reports every legitimate `{record.id}` and `{recordId}`: measured at **7 findings, all 7 false positives**, on this repo's own example apps. So the new rule is a second rule id with the flow dialect as its reference set, and `filter-token-unknown`'s surface list is untouched.
10+
11+
Reported (`error`): a call to a name in neither table — `{TOMORROW()}`, `{ROUND(x)}`, `{Math.round(x)}`, `{DATEADD(day, -45)}`. The flow template dialect's function vocabulary is closed (`round` / `floor` / `ceil` / `abs` / `min` / `max`, plus the whole-token `NOW()` / `TODAY()` with an optional `± N` day offset), and the evaluator already raises a guard refusal on anything else — so the node cannot run at all, and the build was shipping a flow whose runtime was already decided. This is the same severity axis `flow-template-unknown-field` applies at this exact position.
12+
13+
Silent, deliberately: `{TODAY() - 45}` and every other whole-token date form; `{$User.Id}`; `{current_user_id}` / `{today}` / `{30_days_ago}` and the rest of the filter placeholders; and every bare or dotted identifier (`{recordId}`, `{record.id}`, `{currentTask.id}`), which addresses the run's variable map — declared flow variables, node outputs, and the trigger record's own fields — and is not decidable from authored metadata.
14+
15+
Finding delta on this repo's example apps: **0**. Expect a new `error` only where a flow filter calls a function the evaluator would refuse at run time.

0 commit comments

Comments
 (0)