Skip to content

Commit 61b7fc4

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-14451-duplicate-non-base-refusal
2 parents fd29c1d + 21476d7 commit 61b7fc4

18 files changed

Lines changed: 2451 additions & 153 deletions
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
---
2+
"@objectstack/objectql": minor
3+
"@objectstack/cli": minor
4+
---
5+
6+
feat(objectql,cli): `backfillSummaryNulls` accepts `recomputeUndefinedOnEmpty` — a caller who KNOWS a `min`/`max`/`avg` roll-up column was just declared can have it filled; `os migrate summary-nulls --recompute-undefined-on-empty object.field` surfaces it (#15064)
7+
8+
A roll-up value has three producers — the insert-time seed, the child-write
9+
recompute, and the one-off backfill — and **declaring a summary field on an
10+
object that already has rows reaches none of them**. For `count`/`sum` the
11+
backfill repairs that as a side effect (every `NULL` is a hole to it). For
12+
`min`/`max`/`avg` it could not: `summaryNullIsBackfillable` decides on the
13+
function alone, so "never computed" and "no child rows" were indistinguishable,
14+
the column stayed `NULL` on every pre-existing parent, and the report said
15+
`filled: 0` — a false all-clear that a timed flow built on the column then
16+
turned into "matches nothing" (the customer case behind cloud#1908).
17+
18+
**What changes** — maintainer ruling on #15064, option A: the caller who holds
19+
the fact gets a way to say it; the predicate and the default run do not move.
20+
21+
- `SummaryBackfillOptions.recomputeUndefinedOnEmpty?: string[]``object.field`
22+
roll-ups the caller knows were never computed. A named `min`/`max`/`avg` is
23+
walked like a `count`: every `NULL` parent is recomputed through the same
24+
`aggregateSummaryValue` the engine writes. A parent whose aggregate is the
25+
empty-set reading (`null` — no child rows) already holds the engine's own
26+
value, so it is neither counted as a hole nor written; the scoped run is
27+
therefore idempotent in the same "re-run until it reports zero" sense.
28+
Naming a `count`/`sum` is accepted and changes nothing, so a publish path can
29+
pass every column it just declared without knowing the empty-set list.
30+
- A name that resolves to no roll-up owned by an object the run walks — a typo,
31+
a plain field, or an object `objects` left out — is **refused before any row
32+
is read**, dry run or apply, with an ADR-0112 envelope (`code:
33+
'INVALID_FIELD'`, `status: 400` — the code the projection and write axes
34+
that name a field already answer, while sorting keeps `INVALID_SORT`;
35+
`field` names the first unresolved entry, `fields` all of them). A silent
36+
no-op there would be the same false all-clear this option exists to end.
37+
- `SummaryBackfillReport.recomputedUndefinedOnEmpty: string[]` — the complement
38+
of `skippedUndefinedOnEmpty`, same `object.field (fn)` spelling; `[]` on an
39+
unscoped run. `SummaryBackfillFieldOutcome.fn` widens from `'count' | 'sum'`
40+
to every roll-up function, since a named `max` now appears in `fields`.
41+
- `os migrate summary-nulls --recompute-undefined-on-empty object.field`
42+
(repeatable) passes the scope through; the confirmation prompt names the
43+
columns; `formatSummaryBackfillReport` lists them under "Recomputed on
44+
request" and explains a `NULL` that remains.
45+
46+
**What does not change:** without the option the walk, the writes, every
47+
counter and the human-readable report are byte-for-byte what they were (pinned
48+
against output captured on `main` before this change); `min`/`max`/`avg` stay
49+
out of scope and keep being reported under `skippedUndefinedOnEmpty`; the
50+
predicate `summaryNullIsBackfillable` is untouched, so `os migrate
51+
summary-nulls` keeps its meaning on every deployment. The only visible delta on
52+
an unscoped run is the one additive report key, `recomputedUndefinedOnEmpty: []`.
53+
54+
`minor` for both packages: an optional parameter on a published exported
55+
function, a new report key, and a new CLI flag are each a purely additive
56+
widening of a published surface, which takes at least `minor` (bump-level rule,
57+
2026-09-04); the `fix`-shaped motivation does not lower it.
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
"@objectstack/service-automation": patch
3+
---
4+
5+
Flow templates: `{TODAY() + n}` and `{TODAY() - n}` now do their day arithmetic on the same calendar they render on (UTC), so the resolved date no longer lands a day off across a DST transition.
6+
7+
The offset branch of the template resolver shifted the day on the **local** calendar (`getDate` / `setDate`) and then rendered the result on the **UTC** one (`toISOString`). `setDate` preserves wall-clock time, so a local day shift moves the underlying instant by exactly n x 24 hours only while every local day in the window is 24 hours long. Across a spring-forward the window is 23 hours and across a fall-back 25, and when that one hour of slack crosses a UTC midnight the rendered date comes out a day early (spring-forward) or a day late (fall-back).
8+
9+
The window is narrow — roughly one hour per DST-observing zone, twice a year — but the values written through it persist: a quote expiration, a follow-up date, a close date. Measured across 34 zones at every 30 minutes of 2026 for offsets `+1` and `-1` (1,191,360 instant-offset pairs), the old spelling disagreed with the UTC day in 190 of them, spread over 24 DST-observing zones; the new spelling disagrees in none.
10+
11+
The same branch serves `{NOW() + n}`, which likewise now moves the instant by exactly n x 24 hours instead of preserving a wall-clock time across the transition.
12+
13+
Nothing else moves. The bare `{TODAY()}` and `{NOW()}` forms never entered this branch and are byte-for-byte unchanged — they already resolved on UTC, and the offset forms now agree with them. This is not a timezone feature: these tokens remain timezone-unaware by design, and whether they should be is a separate question.

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
# Dependencies
22
node_modules/
3+
# also without the slash: in a linked worktree node_modules can be a symlink, which is not a directory to git and so escapes the line above; the repo tracks node_modules in no shape (#15763)
4+
node_modules
35
package-lock.json
46

57
# Build output

content/docs/api/error-catalog.mdx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,13 @@ reads those as field filters, so one naming no field could only match zero
7575
records and is rejected rather than answered with an empty page — plus every
7676
other read axis that names a field: `select`, `expand` (a real field that holds
7777
no reference gets its own message), `searchFields` (a real field outside the
78-
searchable set gets its own message), `groupBy`, and `aggregations[].field`.
78+
searchable set gets its own message), `groupBy`, and `aggregations[].field`.
79+
Off the request path the same code answers `backfillSummaryNulls`'s
80+
`recomputeUndefinedOnEmpty` (`os migrate summary-nulls
81+
--recompute-undefined-on-empty object.field`) when an entry is not a roll-up
82+
owned by an object the run walks — a typo, a real non-summary field, or a
83+
roll-up on an object `--object` left out are refused alike, one message naming
84+
every unresolved entry and how many objects the run walked.
7985
**Fix:** Check the object schema for valid field names. Use `os meta get object <name>` to inspect the object's fields. If the name was meant as a
8086
*parameter* rather than a field, use the real one — page size is `top` / `$top`
8187
/ `limit`, not `pageSize` / `perPage`; the response's `error` names the

content/docs/deployment/cli.mdx

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -912,6 +912,8 @@ os migrate summary-nulls # Dry run: full report, writes nothi
912912
os migrate summary-nulls --apply # Recompute and write (prompts)
913913
os migrate summary-nulls --apply --yes --json # CI / scripts
914914
os migrate summary-nulls --object project # Restrict to one object (repeatable)
915+
os migrate summary-nulls --apply --recompute-undefined-on-empty customer.last_follow_up_at
916+
# Also fill a min/max/avg column you know was never computed
915917
```
916918

917919
**Each affected row is recomputed, not set to 0.** A pre-upgrade parent that
@@ -920,9 +922,25 @@ them — writing 0 there would replace a missing value with a wrong one, and the
920922
next child write would change it back. The report separates the two: `N NULL
921923
row(s), M with real child data`.
922924

923-
`min` / `max` / `avg` are **never touched**. They are undefined on an empty set,
924-
so a `null` there is the correct reading of "no child rows"; the report lists
925-
them as deliberately skipped.
925+
`min` / `max` / `avg` are **never touched by default**. They are undefined on an
926+
empty set, so a `null` there is the correct reading of "no child rows"; the
927+
report lists them as deliberately skipped.
928+
929+
The one case that reading gets wrong is a summary field **declared after its
930+
parent rows already existed**: nothing has ever computed it — the insert-time
931+
seed is create-time, the recompute runs only on a child write — so every
932+
pre-existing parent reads `NULL` whether or not it has children, and a flow
933+
built on the column matches nothing. The migration cannot tell that `NULL`
934+
from a legitimate one; the operator (or the publish path) who just declared
935+
the column can. Name it with `--recompute-undefined-on-empty object.field`
936+
(repeatable) and it is walked like a `count`: every `NULL` parent is recomputed
937+
through the same aggregate the engine writes, a parent with no child rows keeps
938+
`NULL` (that is the aggregate's own value, and it is neither counted nor
939+
written), and the report lists the column under "recomputed on request". A
940+
name that is not a roll-up this run walks — a typo, a plain field, or an object
941+
`--object` left out — is refused before any row is read. Naming a `count` /
942+
`sum` is accepted and changes nothing, so a caller can pass every column it
943+
just declared.
926944

927945
Idempotent — every write turns a `NULL` into a number, so a second run finds
928946
nothing and writes nothing. Re-running until the report says zero *is* the

docs/adr/0087-metadata-protocol-upgrade-contract.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -585,6 +585,7 @@ gate's log and its `--list` output.
585585
<!-- adr-0087: not-required (no-migration-prescription) <why> -->
586586
<!-- adr-0087: not-required (runtime-interface-only <path>#<Symbol>[, ...]) <why> -->
587587
<!-- adr-0087: not-required (type-surface-only <path>#<Symbol>[, ...]) <why> -->
588+
<!-- adr-0087: not-required (type-surface-only <path>#<a>.<b>.<member>[, ...]) <why> -->
588589
```
589590

590591
**The vocabulary is closed, and every exemption is re-verified on every run** — an
@@ -676,6 +677,7 @@ The vocabulary above gained a sixth answer:
676677

677678
```text
678679
<!-- adr-0087: not-required (type-surface-only <path>#<Symbol>[, ...]) <why> -->
680+
<!-- adr-0087: not-required (type-surface-only <path>#<a>.<b>.<member>[, ...]) <why> -->
679681
```
680682

681683
### The dead end it closes
@@ -748,6 +750,23 @@ checks all four by name (`published`, `no-spec-diff`, `no-metadata-surface-diff`
748750
*both* revs and requires `any` / `unknown` / no annotation at base, and a
749751
concrete type at HEAD.
750752

753+
**The reference is a bare symbol OR a dotted member path.** `<path>#<Symbol>`
754+
resolves a bare name to the **first same-named definition in the file**, and on a
755+
real SDK module that is not a symbol identity: `packages/client/src/index.ts`
756+
declares `get` 14 times and `delete` 10 times, so the members PR #15445 and PR
757+
#15451 actually narrowed had **no addressable spelling at all** — the category
758+
was closed to them by the grammar rather than by any judgement about the claim,
759+
and the gate answered a true sentence about a member the diff never touched
760+
(#15627). A reference may therefore also be written `<path>#<a>.<b>.<member>`:
761+
the object-literal nesting the member sits in, walked **structurally** from the
762+
top of the file over a comment- and literal-masked projection, with the member's
763+
definition taken from inside the resolved body. Bare references keep their exact
764+
previous meaning. ⛔ A line number is never the disambiguator — this file's line
765+
numbers were measured to rot within one day. A dotted path that resolves to
766+
**zero** candidates, or to **more than one**, is reported by name and refused,
767+
never guessed at: a reference that silently landed on the wrong same-named member
768+
would be writable but wrong, which is worse than the refusal it replaces.
769+
751770
### ⭐ Predicate 4 is what makes this a narrowing rather than a hole
752771

753772
This is the **only** category exempt from the `no-migration-prescription` refusal.

0 commit comments

Comments
 (0)