Skip to content

Commit f3c28d2

Browse files
committed
merge origin/main into #4914 — union-keep both retirement registrations, regen from the merged tree
Second merge lap (the first was left mid-conflict by an external cancellation and was aborted rather than completed against a stale base — main had moved 17 commits past its MERGE_HEAD). Source conflict, one file: - `packages/spec/src/migrations/registry.ts` — purely additive. #6815 appended `data/AggregationNode:distinct` to `RETIRED_KEYS_BY_MAJOR[17]` while this branch appended `kernel/Manifest:loading`. Union-keep, both retained (#6526). Generated artifacts regenerated from the merged tree, never hand-merged: `spec-changes.json`, `protocol-upgrade-guide.md`, `authorable-surface/`, `json-schema.manifest/`, `authorable-defaults/`, `api-surface/`, `export-origins/` (new in the Type Check job via #7090), `content/docs/references/`, the strictness-ledger counts. Both ratchets re-fired on the merged inputs and were re-answered: the json-schema.manifest deletion gate accepted the 11 declared def removals, and the authorable-surface deletion gate deferred its 10 def-level deletions (67 key lines) to that gate by route 3.
2 parents 7373318 + f5a9bc2 commit f3c28d2

177 files changed

Lines changed: 15129 additions & 1566 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: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
---
2+
'@objectstack/spec': major
3+
'@objectstack/objectql': major
4+
---
5+
6+
refactor(spec,objectql)!: retire `AggregationNode.distinct` — one face honoured it, five ignored it, and the same query answered two plausible numbers (#6815, ADR-0049)
7+
8+
<!-- adr-0087: registered aggregation-node-distinct-retired -->
9+
10+
**FROM → TO:** `{ function: 'count', field: 'x', distinct: true, alias: 'a' }`
11+
`{ function: 'count_distinct', field: 'x', alias: 'a' }` — the deduplicating spelling
12+
every backend computes, lowered to `COUNT(DISTINCT x)` on both SQL faces since #6409.
13+
`{ function: 'sum' | 'avg' | 'min' | 'max', …, distinct: true }` → delete the key; there is
14+
no replacement, because no SQL backend ever computed `SUM(DISTINCT …)` here and the
15+
in-memory fallback was the only thing that did. `distinct: false` → delete the key; it
16+
selected the behaviour that is now the only behaviour.
17+
18+
`AggregationNode.distinct` was read by exactly ONE of the six faces that consume an
19+
`aggregations[]` entry. `objectql`'s in-memory fallback (`in-memory-aggregation.ts`)
20+
deduplicated the values before applying the function; `SqlDriver.aggregate`, the Turso
21+
`RemoteTransport.aggregate`, `driver-mongodb`'s `buildAggregationStage`, `driver-memory`'s
22+
`computeAggregate` and `service-analytics`' `AGGREGATE_SQL` all ignored it. So
23+
`{ function: 'sum', field: 'amount', distinct: true }` returned a deduplicated sum when the
24+
engine fell back in memory and an ordinary sum on every SQL datasource — one query, two
25+
numbers, chosen by which backend answered. The engine picks that path per query (a driver
26+
without native aggregation, a non-UTC date bucket, a partial SQL driver), so the number
27+
could move under a dashboard with nothing changing in the query.
28+
29+
That is the divergence class #6203 and #5907 each closed on the aggregate axis, still open
30+
on this key, and it is worse to leave: both answers are plausible NUMBERS rather than a
31+
refusal, so nothing surfaced it. It survived the #4286 sweep of this same schema because
32+
that sweep asked which members no executor reads — the wrong question for a key whose
33+
defect is *which* executor reads it.
34+
35+
REMOVE rather than ENFORCE, per the maintainer ruling of 2026-08-09: `count_distinct`
36+
already covers the only deduplicating spelling with measured demand and took ADR-0049's
37+
enforce leg in #6409, while `SUM(DISTINCT …)` / `AVG(DISTINCT …)` are near-universally a
38+
modelling mistake and would have to be lowered across five faces — two of them frozen under
39+
#5499 — to buy it.
40+
41+
The retirement kit:
42+
43+
- **Tombstone, not deletion** (`retiredKey()`): `AggregationNodeSchema` is not `.strict()`,
44+
so a plain delete would let existing queries parse clean and lose the key in silence
45+
(#3733, ADR-0104) — trading a divergent flag for an ignored one. Authoring it is now a
46+
`tsc` error at the call site and a parse error carrying the prescription. One tombstone
47+
covers every aggregation door: `QuerySchema.aggregations` and
48+
`EngineAggregateOptionsSchema.aggregations` both reuse that one schema by reference.
49+
- **ADR-0087 D3 `SemanticMigration`** (`aggregation-node-distinct-retired`) plus the exact
50+
`RETIRED_KEYS_BY_MAJOR[17]` entry `data/AggregationNode:distinct`. No D2 conversion,
51+
deliberately: `QueryAST` is a request surface — the client SDK builder's output and the
52+
`POST /data/:object/query` body — never stored in stack metadata, so there is no source
53+
for `os migrate meta` to rewrite. That is the disposition every other `data.query.*`
54+
retirement in this major already takes (#4286).
55+
- `objectql`'s in-memory fallback loses its `collectValues` dedupe limb — the whole runtime
56+
cost of the removal. **The observable numbers change on that one path, and that is the
57+
point:** a `sum`/`avg` that used to be deduplicated there now answers what every SQL face
58+
has always answered for the same query. Verify against the SQL answer, not against the
59+
pre-upgrade fallback answer — the two disagreed.
60+
- Measured blast radius inside the fallback, narrower than the key suggests: only `sum` and
61+
`avg` ever changed answer. `count` returned from its own branch before reaching the
62+
dedupe, `count_distinct` fed the values into a `Set` (dedupe-then-`Set` is `Set`), and
63+
dedupe does not move `min`/`max`.
64+
- `POST /api/v1/data/:object/query` answers `400 VALIDATION_FAILED` with a `fields[]` entry
65+
at `aggregations.<i>.distinct` instead of serving a number — the #3899 entry validation
66+
descending into the array, pinned in the REST request-schema conformance gate.
67+
- Liveness ledger (`query.json` `aggregations.children.distinct``dead`, README counts),
68+
generated baselines (`authorable-surface/data.json` gains `[RETIRED]`),
69+
`spec-changes.json`, the upgrade guide and the reference docs regenerated.
70+
71+
`count_distinct` is untouched and remains the live deduplicating spelling.
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
---
2+
"@objectstack/spec": minor
3+
---
4+
5+
fix(spec): `$between` accepts the ISO/clock strings the platform itself produces (#6571)
6+
7+
The sibling half of #5685. Both of `$between`'s endpoints declared
8+
`number | Date | FieldReference` — and the platform's own producers put a
9+
**string** in them. As with the four ordering slots, the declaration did not
10+
merely under-describe reality, it contradicted it, and in the one slot where a
11+
date window is the natural spelling:
12+
13+
- **The date-macro resolver descends into arrays.** `resolveFilterTokens`
14+
(`@objectstack/core`, `filter-tokens.ts`) has an explicit array arm in its
15+
`walk`, so a tuple comparand is resolved member by member, and every branch of
16+
the resolver returns a string. `{ close_date: { $between:
17+
['{current_year_start}', '{current_year_end}'] } }` becomes
18+
`{ close_date: { $between: ['2026-01-01', '2026-12-31'] } }` — two endpoints of
19+
exactly the type this schema declared it refused.
20+
- **This package's own conformance corpus already spells it.**
21+
`temporal-conformance.ts`, the shared cross-driver expectation table, states
22+
three `$between` cases with string endpoints: a `datetime` range with its
23+
`{90_days_ago}`/`{today}` token twin, the degenerate single-day range, and
24+
`{ at: { $between: ['08:00:00', '18:00:00'] } }` on a `Field.time` column.
25+
- **The driver already normalises both ends per column type.**
26+
`SqlDriver.coerceFilterValue` recurses through arrays member-wise, and
27+
`calendarDayBetweenRewrite` coerces the min and rewrites a bare-calendar-day
28+
max into the half-open `< next-day(max)` bound (#3777).
29+
30+
**This is additive and declaration-side only.** No producer, caller or driver
31+
changed, and no compile surface needed to: the endpoints were already being
32+
normalised driver-side by column type, so every filter that validated before
33+
still validates.
34+
35+
Widened in all three places this contract is spelled — `RangeOperatorSchema`
36+
(documentation), `FieldOperatorsSchema` (the copy `NormalizedFilterSchema`
37+
validates against and `FieldOperators` is inferred from), and the `Filter<T>`
38+
TypeScript helper. #5685 moved the documentation copy first and had to come back
39+
for the reachable one; both spellings move together here.
40+
41+
In `Filter<T>` the guard stays type-precise because `T` is known, mirroring the
42+
ordering guard slot for slot: a `Date` field also takes the resolver's ISO
43+
strings, a `string` field (a `Field.time` `'08:00:00'`, an autonumber code) is
44+
rangeable instead of collapsing to `never`, and a `number` field stays
45+
numbers-only. Each endpoint is widened independently, so a partially-resolved
46+
range (`[Date, '2026-12-31']`) type-checks.
47+
48+
**The endpoint form the contract guarantees** is the ISO/clock one — an ISO
49+
calendar day (`YYYY-MM-DD`), a UTC ISO-8601 instant, or a wall-clock time of day
50+
(`HH:MM[:SS[.fff]]`). Those are ASCII and fixed-width, so lexicographic order IS
51+
chronological order and every backend agrees. The union is a bare `string`
52+
rather than an ISO refinement for the reasons #5685 measured and this change
53+
re-measured for the tuple: the schema is field-agnostic, an ISO refinement would
54+
reject `Field.time`'s declared `HH:MM` form, and date-only vs full-timestamp is
55+
already reconciled by the driver. Ranging over **non-temporal** text is
56+
therefore permitted but not promised — the order is the backend collation's —
57+
and nothing here promises the endpoints are ordered relative to each other: an
58+
inverted `[max, min]` range is well-formed and matches nothing, at every backend.
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
---
2+
"@objectstack/formula": patch
3+
---
4+
5+
fix(formula): the CEL hydration retry arms off cel-js's structured code, not the phrase "no such overload" (#6679)
6+
7+
`celEngine.evaluate` catches a fault and asks `isNumericOverloadError` whether to
8+
hydrate string-serialized numeric / date fields and re-evaluate once — the
9+
ADR-0032 §1c accommodation for `Field.rating``"5.0"` and `Field.date`
10+
`"2026-06-20"` (#1530, #1534). That question was answered by
11+
`/no such overload/i.test(err.message)`: the last message-text read in
12+
`cel-engine.ts` that armed behaviour after #6223 / PR #6677 closed the same hole
13+
in `classifyError`. It now reads
14+
`err instanceof EvaluationError && err.code === 'no_such_overload'`, the same
15+
class-and-code rule `classifyCelFault` already follows one function below.
16+
17+
The phrase was reachable from a **native** throw, not only from cel-js. Our
18+
`matches()` stdlib binding is `new RegExp(String(re)).test(...)`, so an
19+
uncompilable pattern escapes cel-js unwrapped as a `SyntaxError` echoing the
20+
pattern verbatim — `Invalid regular expression: /no such overload(/` — which
21+
matched. The pattern can be written in the source or read off a row via
22+
`matches(record.name, record.re)`.
23+
24+
The filing recorded this as observation-class, expecting the consequence to be
25+
nil because the retry re-throws the original error. Measuring it for the fix
26+
found one case where it is not nil, so this ships as a fix rather than a
27+
tolerance removal: when hydration lets the expression short-circuit around the
28+
throwing call, the spurious retry **succeeds** and returns a value where the
29+
fault was the right answer.
30+
31+
```text
32+
record.s == "5.0" ? matches(record.name, "no such overload(") : false
33+
{ s: "5.0", name: "x" } -> was: ok, false now: the regex fault
34+
record.s == "5.0" ? matches(record.name, "(") : false
35+
{ s: "5.0", name: "x" } -> the regex fault (unchanged)
36+
```
37+
38+
Evaluation 1 takes the `matches(...)` branch and throws natively; the phrase
39+
armed the retry; hydration made `record.s` the number `5`, so `5 == "5.0"` went
40+
false, the ternary took the other branch, and `matches` was never called. Two
41+
expressions that differ only in whether a regex literal happens to contain the
42+
phrase no longer disagree about whether they fault.
43+
44+
The behaviour change is one-directional and narrow. A genuine cel-js
45+
`no_such_overload` still arms the retry and every §1c hydration behaves exactly
46+
as before; only a native throw whose message merely contains the phrase stops
47+
arming it. Faults are otherwise unchanged — a native throw carries no cel-js
48+
contract, so it is still reported as `runtime` (#6223).
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
---
2+
"@objectstack/driver-sql": patch
3+
---
4+
5+
fix(driver-sql): a merge-path `upsert` no longer rewrites an existing row's autonumber (#7011)
6+
7+
Measured on a completely healthy counter, single row throughout:
8+
9+
```
10+
create → CASE-00001 last_value 1
11+
upsert same id (1st time) → CASE-00002 last_value 2
12+
upsert same id (2nd time) → CASE-00003 last_value 3
13+
```
14+
15+
`fillAutoNumberFields` reserves a number before the statement knows whether it
16+
will insert or merge, and the autonumber column sat in `mergeColumns` — so
17+
every `ON CONFLICT … DO UPDATE` wrote the freshly reserved number over the
18+
row's existing one, silently replacing an externally visible business
19+
identifier the caller never asked to change.
20+
21+
Per the triage ruling on the card: an autonumber is an **immutable business
22+
identifier once assigned**. `auto_number` columns are now excluded from the
23+
merge column list, exactly like `created_at` (both are insert-only facts about
24+
the row's birth). After the fix the same sequence keeps `CASE-00001` through
25+
both upserts. The exclusion is unconditional — an explicit autonumber value in
26+
the upsert payload does not renumber an existing row on the merge branch
27+
either; `update()` writes what it is given and remains the deliberate
28+
renumbering path. Insert-path upserts still assign fresh numbers, and every
29+
non-autonumber column (including `updated_at`) merges as before.
30+
31+
Deliberately out of scope (#6943's reseed family): the reservation itself still
32+
happens before insert-vs-merge is known, so a merge-only upsert still consumes
33+
one sequence value per call — now a permanent gap in the sequence rather than a
34+
rewrite of the row (measured post-fix: row keeps `CASE-00001`, `last_value`
35+
walks 1 → 2 → 3, the next inserted row gets `CASE-00004`).
36+
37+
Covered faces: `SqliteWasmDriver` inherits `upsert` unchanged; `TursoDriver`
38+
local/replica routes its override to `super` — both pinned by their own tests.
39+
Turso remote (`RemoteTransport.upsert`) never enters `fillAutoNumberFields` and
40+
has neither the defect nor the fix. Rows already renumbered by past merges
41+
cannot be restored from the driver side.
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
---
2+
'@objectstack/spec': patch
3+
---
4+
5+
The export-surface pins compare a build-time baseline instead of running `tsc` (#4796).
6+
7+
Seventeen pin tests across thirteen files answered the same question — "which source declaration does entry point X export under name Y?" — and each answered it by building its **own** `ts.createProgram` over all sixteen entry points and running `getTypeChecker()` inside a vitest `it()`. That resolution is now a checked-in artifact, `packages/spec/export-origins/<entry>.json`, and the pins compare against it.
8+
9+
**The cost this removes was measured, not estimated.** On this container the thirteen affected files spent **76.3s** of aggregate test time, of which the seventeen compiler cases were **55.2s** — and the pool grew by one full compilation per retirement PR (the card counted 12 files; there were 18 by the time this was written). It was also non-deterministic in the way that matters: at ~3.4s per case against vitest's 5s default, a loaded merge-queue runner pushed them over the line six times in one night, each time ejecting a PR that had never touched `packages/spec`. Two stop-the-bleed laps raised the timeout (#4856, then #4864); neither saved a millisecond of compilation, because the compilation was never the cause — it was the material.
10+
11+
**A comparison is only as good as the thing compared, so freshness is guarded twice, independently.** `check:export-origins` recomputes from source and compares bytes; it runs inside `check:generated`, hence inside lint.yml's required `TypeScript Type Check` job, so a stale or hand-edited artifact is CI-red. And the pins carry a second guard that needs no compiler at all: every origin whose kind survives to runtime is cross-checked against the entry's real namespace object, so `pnpm test` on its own is not blind to a doctored artifact either. Type-only exports are erased at runtime and are covered by the first guard, which covers everything — two gates that fail for different reasons beat one gate that has to be believed.
12+
13+
**Every pin's claim has a successor that fails under the same condition**, and two of them are strictly tighter rather than equal: the retired pins asserted a declaration's position as `<file>:<line>` with the line matched as `\d+`, i.e. never, so the successors assert the declaring file exactly. The artifact deliberately records no line number — recording one would rewrite it on every edit that shifts a line in any `.zod.ts`, turning a comparison baseline into the repo's next merge-conflict magnet. For the same reason the ten `./contracts` exports that resolve into `ai` / `@ai-sdk/provider-utils` have their pnpm-store version and peer-hash segments normalised away.
14+
15+
Sharded per entry point, following `api-surface/` and for its reason (#5837): retirement PRs rewrite whichever entries they touched, and the merge queue rebuilds server-side where no custom merge driver runs, so two PRs retiring names on different entries must touch disjoint files.
16+
17+
**Two `createProgram` cases are deliberately left as they are.** `data/driver.test.ts` and `ui/app.test.ts` compile a single file to assert that a retired key is *unwritable in the authored type* — a different fact from export origin, which no export-surface baseline can carry. `contracts/sharing-service.test.ts` parses one file with `createSourceFile` to read TSDoc; that is a syntactic parse with no program and no checker, and costs nothing. Naming them here rather than leaving the reader to wonder why `grep typescript` still finds hits.
18+
19+
No runtime or published-surface change: this is a test and tooling change plus one new generated artifact.
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
---
2+
"@objectstack/spec": patch
3+
---
4+
5+
docs(spec): re-measure and rewrite four expired post-#5702/#5710 status claims in `src/data` (#6993)
6+
7+
Four status sentences in `filter-text-conformance.ts`, `filter-text-conformance.test.ts`
8+
and `filter.zod.ts` still described the pre-#5702/#5710 world as current: "`$icontains`
9+
… implemented by nobody", "a standard no backend answers yet", the `$regex` retirement
10+
block's "hard order: #5710 flips the producer, then #5702 turns these strings into
11+
refusals" (both gates fired since), and "`$options: 'i'` are #5702's work" (the fold is
12+
still there; its owner is #6682 now). Each was re-measured by executing every face —
13+
the five drivers (both turso transports), `formula`, objectql `having`, the analytics
14+
read-scope compiler — plus a fresh run of `scripts/check-driver-conformance.mjs`, and
15+
rewritten to state the shipped reality with dated re-verification markers, pointing at
16+
the gate-maintained conformance ledger instead of hand counts where one exists.
17+
18+
No behaviour change: no operator added to `FILTER_OPERATORS`, no refusal or assertion
19+
touched, generated artifacts byte-identical. The one measured gap the census surfaced
20+
(objectql `having` refuses retired operators outside the ADR-0112 envelope) is filed
21+
as #7047, not fixed here.

0 commit comments

Comments
 (0)