Skip to content

Commit ae43bfa

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-15169-storage-routes-public-seam
2 parents a697366 + ed7243d commit ae43bfa

49 files changed

Lines changed: 4333 additions & 205 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.

.changeset/aggregate-field-type-compatibility.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,9 @@ A dataset measure pairs an `aggregate` with a `field`, and nothing between autho
1616

1717
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.
1818

19-
Two refused rows override existing opinions and are recorded as such, not presented as agreement. **Booleans** are refused for `sum` / `avg` / `min` / `max` by the ruling's "every other pair: refused", while maintainer ruling #11152 already 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). That refusal is therefore not grounded in backend divergence; whether booleans belong in those rows is a collision between two rulings and is referred to the maintainer as its own decision — the row ships exactly as batch #59 stated it. **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.
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.
2020

21-
**The narrowing, stated plainly.** Every pair outside the table — `avg` × `datetime`, `sum` × `boolean`, `min` × `text`, `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.
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.
2222

2323
**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.
2424

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.
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
"@objectstack/lint": patch
3+
---
4+
5+
`validateStackExpressions` no longer throws on a non-record entry of a flow's `nodes` list.
6+
7+
An empty item in a YAML `nodes:` list deserialises to `null`, so this is an authorable shape — the same one #15552, #15636 and #15742 closed for stack collections and for `objects[].fields`. Here it crashed the linter instead of producing a finding: `flow.nodes: [null, ...]` threw `Cannot read properties of null (reading 'type')`, which presents to an author as a broken tool rather than as a problem with their metadata.
8+
9+
Both of the file's inline casts now read through `recordsOf`, the one home of this coercion, instead of asserting that `Array.isArray` proves anything about a list's MEMBERS:
10+
11+
- The flow walk reads `flow.nodes` through `recordsOf`, and — the half that actually removes the crash — hands that coerced array to `collectFlowGraphs` rather than the raw flow. `collectFlowGraphs` declares its input as already-parsed `FlowNodeParsed[]` and is transparent about members, so passing raw authored metadata was calling it out of contract; coercing only the local variable relocated the throw into `@objectstack/spec` instead of ending it. The producer's contract is unchanged, deliberately: widening it to tolerate malformed members is the wrong direction.
12+
- The per-graph walk reads `graph.nodes` through `recordsOf` in place of an `as unknown as` double cast. A nested region's node list is only `Array.isArray`-checked before it becomes a graph, so that list carries the producer's word about its members and not a check.
13+
14+
A non-record member is dropped whole and in silence, exactly as the file's sibling field readers already did; a flow standing beside the junk entry is still judged, and a `nodes` list holding a plain string still reports exactly what it reported before.
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
"@objectstack/cli": patch
3+
---
4+
5+
`os generate <type> <name>` no longer exits 0 after writing TypeScript the compiler cannot parse.
6+
7+
The command ran no name validation of any kind — no `validateProjectName`, no sanitiser — so the name went into a binding position untouched. `os generate object foo.bar` reported success and left two broken files behind: `const foo.bar: Data.ServiceObject = {` in `src/objects/foo.bar.object.ts`, and a matching `export { default as foo.bar } from './foo.bar.object';` appended to the barrel `src/objects/index.ts`. The author learned about it at the next `tsc`, in a file the scaffolder had just told them it created.
8+
9+
Both emissions are now rendered once, at the single point where the derived identifier is finished, and handed to TypeScript's own parser before anything is written. If either does not parse, the command prints the compiler's own diagnostics for each affected file and exits 1 without touching the filesystem — including under `--dry-run`, where a preview of un-parseable output under exit 0 is the same defect in preview form. One check covers all 14 emission sites across all 7 generators (`object`, `view`, `action`, `flow`, `dashboard`, `app`, `skill`) plus the barrel, and a generator added later inherits it.
10+
11+
- **The criterion is parseability, not a charset.** Nothing is rewritten and no name that already produced parseable output is refused: the accepted set moves only by the names whose emission was already broken. Which names `os generate` should accept — and whether it should normalise the ones it does, the way `os create` derives its identifier — is a separate, open decision. Deriving a legal-looking identifier from a name that should have been refused is the worse of the two failures, so this refuses loudly rather than answering that question by widening tolerance.
12+
- **Asking the compiler is what makes the check correct per emission position.** A rule about identifier characters, or about reserved words, gets this wrong in both directions: `os generate object class` is refused (`const class:` is not a declaration) while `os generate view class` is accepted (that generator emits `const classViews:`), and a name carrying a quote or a comment terminator breaks the emitted file without touching the identifier at all.
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
"@objectstack/plugin-security": patch
3+
---
4+
5+
An RLS `check` clause that reads a membership-resolver key now resolves on a bare insert.
6+
7+
An app that registers an `IRlsMembershipResolver` (ADR-0105 D11) and authors `using` + `check` twins reading its key — `record.employer_org in current_user.employer_org_ids` — saw reads resolve the key and every bare insert refused with `PERMISSION_DENIED` ("would violate a row-level CHECK"), whether or not the payload carried the value the policy wanted. The membership sets were staged onto the request context only inside the read-filter computation; `computeWriteCheckFilter` compiled the `check` clause against a context in which the key had never been staged, so the variable was unresolved, the policy dropped, and the write failed closed. The two write shapes that passed — a by-id update and an insert of a `controlled_by_parent` child — passed only because an earlier read on the same context (the pre-image, the master read) happened to stage it first.
8+
9+
The write path now stages the resolver's sets itself, immediately before the `check` clause compiles, so a `check` resolves exactly the variables its `using` twin resolves regardless of whether the request read first. Staging is memoized per request context, so the read-first shapes still consult the resolver once. Nothing is relaxed: with no resolver registered, a resolver that throws, or a key the resolver does not publish, the policy still drops out and the write is still refused.

0 commit comments

Comments
 (0)