Skip to content

Commit d95e7cf

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-18163-export-hook-api-types
2 parents 3d2de76 + 176b035 commit d95e7cf

122 files changed

Lines changed: 10214 additions & 5018 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: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
---
2+
"@objectstack/spec": minor
3+
"@objectstack/formula": minor
4+
---
5+
6+
feat(spec)!: every engine-evaluated expression slot requires a non-blank `source` — the #15430 rule generalised from the flow-node ledger to the other 36 declaring positions (#15811, decision batch #122 item 2)
7+
8+
<!-- adr-0087: registered evaluated-expression-slots-source-required -->
9+
10+
**BREAKING** accept-set narrowing on 36 published metadata slots. Each of them
11+
composed `ExpressionInputSchema` and now composes `EvaluatedExpressionInputSchema`,
12+
so an envelope carrying only `ast` (`{ dialect: 'cel', ast: … }` with no `source`)
13+
and a `source` that is blank after trimming — through the envelope key or through
14+
the bare-string shorthand — are refused at the door instead of parsing and then
15+
faulting at run time. The prescription is registered under protocol major 18 as
16+
the semantic migration `evaluated-expression-slots-source-required`.
17+
18+
**⚠️ Graded `minor`, not `major`, and the ruling said `major`.** Decision batch
19+
#122 item 3 ordered a 「`major` changeset」. This repo's launch-window convention
20+
ships breaking changes as `minor` while the fixed group versions in lockstep, and
21+
`scripts/check-changeset-no-major.mjs` enforces it: a `major` marker here would
22+
promote all ~70 packages to a whole-stack major release, which is a release act.
23+
The convention's own written carriers for breaking-ness are used instead and both
24+
are present — this **BREAKING** banner and the ADR-0087 disposition above. The
25+
ruling's substance (a breaking narrowing, carried by an ADR-0087 semantic
26+
migration entry) is delivered; only the marker differs, and it differs because a
27+
repo gate forbids the marker.
28+
29+
**What is NOT narrowed.** `ExpressionSchema` / `ExpressionInputSchema` remain the
30+
persistence contract (`source` OR `ast`), by item 2 of the same ruling, and so
31+
does `PredicateInputSchema`, which is a plain alias of the latter. A slot that
32+
only PERSISTS an envelope is untouched; the narrowing is at the slots an engine
33+
EVALUATES. An `ast` carried BESIDE a string `source` stays admitted everywhere.
34+
35+
**The population was re-derived, not inherited.** By identity — a negative
36+
lookaround on identifier characters, so `CronExpressionInputSchema` and
37+
`TemplateExpressionInputSchema` cannot leak in as substrings — over
38+
`packages/spec/src`, non-test: 34 declaring source lines, two of which are
39+
file-local alias consts (`ui/action.zod.ts` `ActionConditionInputSchema`,
40+
`system/settings-manifest.zod.ts` `SettingsVisibilityInputSchema`) that mount two
41+
slots each, giving **36 declaring positions**. Three of them reach the schema as a
42+
union member rather than head-of-declaration (`RecordAlertProps.visible`,
43+
`ServiceLevelIndicator.successCriteria`, `TraceSamplingConfig.composite[].condition`).
44+
45+
On **two of those three the sibling arm is untouched**: `RecordAlertProps.visible`
46+
still takes a boolean literal, and `ServiceLevelIndicator.successCriteria` still
47+
takes its structured `{ threshold, operator, percentile? }` object — including one
48+
that happens to carry a `dialect` key.
49+
50+
⚠️ **On the third, `TraceSamplingConfig.composite[].condition`, the sibling arm
51+
narrows too, and deliberately.** Its structured-filter arm is a bare
52+
`z.record(z.string(), z.unknown())`, which accepted `{ dialect: 'cel', ast }` as an
53+
ordinary filter — so swapping the expression arm changed nothing at all there. That
54+
arm now declines any object carrying a `dialect` key, and six shapes the base
55+
accepted THROUGH THAT ARM ALONE (measured: the base's `ExpressionInputSchema`
56+
refused every one of them) are refused at this slot:
57+
58+
| authored `condition` | base | now |
59+
|---|---|---|
60+
| `{ dialect: 'cel' }` | accepted | refused |
61+
| `{ dialect: 'js', source: 'x' }` | accepted | refused |
62+
| `{ dialect: 'nope', source: 'x' }` | accepted | refused |
63+
| `{ dialect: 'cel', source: 5 }` | accepted | refused |
64+
| `{ dialect: 'cel', source: 'x', meta: { rationale: 5 } }` | accepted | refused |
65+
| `{ dialect: 'zzz', foo: 1 }` | accepted | refused |
66+
67+
FROM → TO at that slot: if the value really is a **structured filter**, drop the
68+
`dialect` key (`{ dialect: 'cel', service: 'api' }` → `{ service: 'api' }`); if it is
69+
an **expression**, give it a dialect this platform evaluates and a non-blank `source`
70+
(`{ dialect: 'js', source: 'x' }` → `{ dialect: 'cel', source: 'x' }`). A structured
71+
filter that carries no `dialect` key — `{}`, `{ service: 'api' }`,
72+
`{ attributes: { 'http.route': '/v1/orders' } }` — is accepted exactly as before.
73+
74+
**Why an authoring-time refusal and not a run-time one.** Measured at the
75+
chokepoint, `celEngine.evaluate` never silently succeeds on either shape — it
76+
returns a `parse` fault — so what happened next was decided entirely by the
77+
slot's fail policy, and the two halves of that population fail in opposite
78+
directions: fail-CLOSED slots (`ObjectFieldGroup.visibleWhen`,
79+
`RowCrudActionOverride.visibleWhen`, `BulkActionDef.visible`, the two
80+
settings-manifest `visible` slots) hid a group, a row button, or silently excluded
81+
every selected record from a bulk run and reported them as *skipped*; fail-SOFT
82+
slots left a gate that had stopped gating. Nothing in between said a word: the
83+
authoring lint `validateVisibilityPredicates` measured 0 findings on an `ast`-only
84+
envelope and 0 on a blank `source`, against two control legs that each measured 1.
85+
86+
**`@objectstack/formula` gains `printCelAst(ast)`** — the inverse of
87+
`parseCelToAst`, and the lossless half of the migration: an `ast`-only CEL
88+
envelope is printed back to surface syntax mechanically, with no judgment asked of
89+
the author. It is lossless about MEANING, not bytes (the printer re-renders from
90+
the parse tree, so `'x'` comes back as `"x"`), and it answers `null` — never a
91+
guess — for anything it cannot round-trip through the platform's own bounded
92+
parser. That `null`, and every blank `source`, are what the semantic migration
93+
entry's structured TODO covers.
94+
95+
**The published TypeScript interface `RowCrudPredicates` narrows with it**
96+
(`Expression | ExpressionInput` → `EvaluatedExpression | EvaluatedExpressionInput`),
97+
because it mirrors the two `RowCrudActionOverride` slots and a type that still
98+
promised an `ast`-only envelope would advertise what the schema now refuses.
99+
100+
**So do the four expression constructors — `expression()`, `cel`, `tmpl`, `cron`
101+
(and therefore the `F` / `P` aliases) — which now return `EvaluatedExpression`
102+
instead of `Expression`.** Each one assigns a `string` to `source`
103+
unconditionally, so the wider return type described none of them; it was slop
104+
that cost nothing until an evaluated slot began requiring `source`, at which
105+
point ``visibleWhen: P`…` `` — the spelling the spec's own docblock teaches —
106+
stopped type-checking, and `@objectstack/platform-objects` failed its DTS build
107+
on exactly that. `EvaluatedExpression` is assignable to `Expression`, so every
108+
persistence-contract slot keeps accepting these values unchanged; what the
109+
narrower return type adds is that an evaluated slot accepts them too. An author
110+
who genuinely has no `source` was never calling these constructors — an
111+
`ast`-only envelope is an object literal, and an evaluated slot refuses it on
112+
purpose.
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
---
2+
'@objectstack/spec': minor
3+
---
4+
5+
**BREAKING for authored metadata** — a `$between` range now requires two endpoints that are present and non-empty. A blank bound (`''` or an absent `undefined` bound, at either side) is refused at the authoring door, and the refusal names the blank side (#18012).
6+
7+
Clause-②: yes
8+
9+
Maintainer ruling A on decision batch #146 item 5, 2026-09-17 「146 同意」.
10+
11+
## What changed, and why it is a new rule rather than a repair
12+
13+
`FieldOperatorsSchema.safeParse({ $between: [1, ''] })` answered `success: true` — measured on the card against spec 17.4.0 and re-measured on `main` before this change. That acceptance was **conformant**: the endpoint contract shared by both bounds says verbatim that "Each endpoint is a number, a Date, or a string", and the empty string is a string. So this narrows a published face by adding a rule to it, rather than pulling code back to a declaration it was already violating.
14+
15+
What made the acceptance wrong is the other half of the same contract — "Closed interval [min, max]" — which no backend can honour against a blank. `driver-sql` binds the blank into `whereBetween`; the JS matchers compare it as a value. Either way the range stops bounding on that side **while still reading as a complete two-element range**, so the query runs with one meaningless boundary and no signal at any layer. The reference matcher was already taught to survive the `null` form of exactly this (a bounded range answered every valued row, because both of the arm's comparisons are false against a missing bound); the door that admitted it was never addressed.
16+
17+
The only producer ever measured is a UI builder padding a **half-typed** pair so a length-based completeness check passes it. Nobody writes a blank bound on purpose — which is why it is refused rather than given a published meaning.
18+
19+
```
20+
FROM FieldOperatorsSchema.safeParse({ $between: [1, ''] })
21+
-> { success: true } // a half-filled range, green all the way
22+
// to the driver
23+
24+
TO FieldOperatorsSchema.safeParse({ $between: [1, ''] })
25+
-> { success: false,
26+
issues: [{ code: 'custom', path: ['$between', 1],
27+
message: 'A blank value is not a valid $between endpoint at index 1
28+
(the MAX bound). …' }] }
29+
```
30+
31+
## Migration — FROM → TO
32+
33+
| You wrote | Write instead |
34+
| --- | --- |
35+
| `{ $between: [1, ''] }` | `{ $between: [1, 100] }` — the upper bound you meant, written out |
36+
| `{ $between: ['', '2026-12-31'] }` | `{ $between: ['2026-01-01', '2026-12-31'] }` — the lower bound you meant |
37+
| a range that was only ever bounded on ONE side | `{ "$gte": min }` or `{ "$lte": max }` — a one-sided bound is not a range |
38+
39+
**The one-line fix: write the bound that is missing, or — if only one side was ever meant — drop `$between` and write that side as a scalar comparison.** ⛔ Not mechanically convertible: the bound the author did not type is not recoverable from the one they did, so this ships as an ADR-0087 D3 structured TODO and **no D2 conversion**. Both of the two readings a conversion could take are wrong — dropping the operator deletes a constraint the author wrote and silently WIDENS the result set, and treating the blank side as unbounded invents a filter nobody authored.
40+
41+
<!-- adr-0087: registered filter-between-blank-endpoint-refused -->
42+
43+
## What does NOT change
44+
45+
- **Arity.** A one-element or three-element `$between` was already refused, and still is, by the tuple's own contract. This rule is about a two-element range one of whose elements means nothing.
46+
- **`null` bounds.** Already refused since 2026-08-31, and they keep **their own** message, which prescribes the null predicate — an author who wrote `null` was reaching for absence, not for a bound. Two blank spellings, two intents, two remedies.
47+
- **Falsiness.** `{ $between: [0, 100] }` and `{ $between: ['0', '9'] }` parse exactly as before. The rule is blankness, not falsiness.
48+
- **Whitespace-only endpoints** are deliberately **not** judged. The ruling is the empty string; widening the refusal past it would narrow a published face further than the ruling did.
49+
- **The set slots.** `{ $in: ['', 'won'] }`, `{ $nin: [''] }`, `{ $eq: '' }` and `{ $gte: '' }` are untouched — an empty string is a legitimate stored VALUE, and only an interval ENDPOINT is judged here.
50+
- **Stored documents.** The read path does not re-validate stored rows, and the stored-row conversion pass neither validates nor drops anything, so no stored view becomes unreadable. What changes is that **re-saving** one is refused, at the endpoint's own path, with the blank side named.
51+
- **The published export surface.** No export is added, removed or renamed; the refusal rides the existing endpoint factory that both the documentation copy (`RangeOperatorSchema`) and the enforced copy (`FieldOperatorsSchema`) already share, so the two cannot drift.
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
---
2+
'@objectstack/spec': minor
3+
---
4+
5+
**BREAKING** — retire `CubeJoin.sql` and `CubeJoin.relationship`. A cube join declares
6+
WHICH object it reaches; the ON clause is derived from the declared relationship between
7+
the two cubes' objects and is never authored.
8+
9+
`CubeJoin.sql` was **required** and described itself as the `ON` clause, and nothing ever
10+
read it. Both analytics strategies synthesise the join: `NativeSQLStrategy` emits
11+
`LEFT JOIN <name> <alias> ON "<parent>"."<segment>" = "<alias>"."id"` from the dotted member
12+
path alone, and `ObjectQLStrategy` resolves the join through `cube.joins?.[alias]?.name` and
13+
lowers it to a relationship traversal with no `ON` clause at all. So an authored join
14+
condition was not ignored — it was **replaced**, under a `200`, by an equality the author had
15+
not asked for, with a plausible number attached. `relationship` is the same shape one key
16+
over: it carried a `.default('many_to_one')`, nothing dispatched on the cardinality, and
17+
`one_to_many` parsed, changed no SQL and kept the many-to-one arithmetic.
18+
19+
ADR-0049 enforce-or-remove; maintainer ruling 2026-09-18 (director batch #154 item 4,
20+
letter 2). The ruling declined the other remedy — executing the author's SQL — as a new
21+
capability whose first design question is an injection boundary, for zero authors today. A
22+
custom join condition, if a customer needs one, is a capability card with that boundary
23+
decided first.
24+
25+
## FROM → TO
26+
27+
| you wrote (17.4 and earlier) | write instead |
28+
| --- | --- |
29+
| `joins: { account: { name: 'crm_account', relationship: 'many_to_one', sql: '${orders}.account = ${crm_account}.id' } }` | `joins: { account: { name: 'crm_account' } }` — delete both keys |
30+
| `joins: { a: { name: 'b', relationship: 'one_to_many' } }` | `joins: { a: { name: 'b' } }` — the cardinality was never read; declare it on the object's own relationship field |
31+
| `joins: { a: { name: 'b', on: '…' } }` | `joins: { a: { name: 'b' } }` — `on` was the curated alias for `sql` and is retired with it |
32+
33+
**The one-line fix:** delete `sql` and `relationship` from every `joins` entry; keep `name`.
34+
35+
Nothing regresses by deleting them: neither key ever reached a query. What decides the join
36+
is `name` (the joined object, which is also what the per-object RLS/tenant read scope is
37+
computed for) and the declared relationship the runtime derives the equality from.
38+
39+
## The retirement kit
40+
41+
- **Strict deletion plus a `guidance` prescription, not a `retiredKey()` tombstone.** Every
42+
cube shape is a `strictObject`, so the key leaves the walked shape entirely and the
43+
refusal carries the upgrade: writing `sql`, `relationship` or `on` on a join is an
44+
`unrecognized_keys` rejection whose message names the key and states that the `ON` clause
45+
is DERIVED from the declared relationship between the two cubes' objects. Same route
46+
`MetricSchema.filters` took one shape over in this same file.
47+
- **`on` is no longer an alias.** It pointed at `sql`; an alias naming a key the shape
48+
cannot accept answers an author with a second rejection, so it became a `guidance` entry
49+
of its own and the rename suggestion is gone. Pinned in both directions.
50+
- **ADR-0087: a D2 conversion AND a D3 semantic entry**, plus the two exact-key
51+
registrations `data/CubeJoin:sql` and `data/CubeJoin:relationship` in
52+
`RETIRED_KEYS_BY_MAJOR[18]`. The conversion is
53+
`cube-join-sql-and-relationship-removed` (`toMajor: 18`,
54+
`retiredFromLoadPath: true`), chained into step 18: it strips both keys from every
55+
`analyticsCubes[].joins.*` wherever the chain is replayed, one notice per stripped site,
56+
each naming the cube that lost the key. It is owed because the removal is measured
57+
against **metadata at rest**, not only against sources: `sql` was required and
58+
`relationship` was defaulted, so every cube artifact ever written from the old schema's
59+
own parse output carries both keys, and the boot door
60+
(`ObjectStackDefinitionSchema` → `analyticsCubes: z.array(CubeSchema)`) would otherwise
61+
refuse it with no remedy short of hand-editing JSON. The strip is lossless in the only
62+
sense that applies: a key that never had an effect has none to lose. The D3 entry
63+
`cube-join-sql-and-relationship-retired` stays as the human-facing record — the strip
64+
removes the key, the entry says why an author who wrote a non-FK `sql` should re-read the
65+
numbers that join produced.
66+
- **The `os migrate meta --from 17` sentence** closes all three prescriptions, which is what
67+
a covered surface owes.
68+
- **The `joins` record KEY is documented.** `name`'s describe now states that the key a join
69+
is declared under is the FOREIGN-KEY FIELD on the cube's own base object — the column the
70+
derived `ON` reads — not a second spelling of the object the join reaches.
71+
- **The liveness ledger rows went WITH the keys** (`liveness/analytics_cube.json`), which is
72+
the strict-deletion route's disposition — the opposite of the tombstone route, which keeps
73+
the row because `retiredKey()` keeps the key in the walked shape. `analytics_cube` drops
74+
from 12 `dead` to 10.
75+
- **The one in-repo producer is fixed in the same diff.** `examples/app-showcase`'s
76+
`DeliveryCube` authored both keys, including an `ON` clause the runtime was replacing;
77+
`dataset-compiler.ts` minted them as two constants no reader consulted. Its join was also
78+
keyed `showcase_project` — the object it reaches — while `showcase_task`'s foreign key is
79+
`project`, so the derived `ON` named a column the base object does not have and the join
80+
never resolved. It is re-keyed `project` here and pinned against the object's own field
81+
map.
82+
83+
Clause-②: yes (narrowing)
84+
85+
<!-- adr-0087: registered cube-join-sql-and-relationship-retired -->

0 commit comments

Comments
 (0)