Skip to content

Commit 408c471

Browse files
committed
Merge origin/main into claude/issue-15742-validate-expressions-non-record-field (resolve validate-expressions.test.ts with #15792)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012zGPuVVX3deAx9LdjK8jCk
2 parents 6e6d3f8 + 6c439f2 commit 408c471

66 files changed

Lines changed: 6349 additions & 178 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: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
---
2+
"@objectstack/runtime": patch
3+
---
4+
5+
fix(runtime): a flat-manifest bundle no longer collects every seed dataset twice
6+
7+
`AppPlugin.start()` collects seed data from two locations — the top-level
8+
`data` field, then the legacy `manifest.data` for backward compatibility. The
9+
legacy read resolves its base as `this.bundle.manifest || this.bundle`, so on a
10+
FLAT bundle — manifest fields written directly on the bundle rather than nested
11+
under `manifest:`, a shape `AppPlugin` supports by design and this repo's own
12+
tests construct — it re-read the very array the top-level read had just
13+
contributed. Every dataset landed in the collection twice.
14+
15+
`mergeSeedDatasets` is a plain `push` with no de-duplication, so both copies
16+
reached the shared `seed-datasets` registry, the inline boot seed, and every
17+
later per-org replay. For an `upsert` dataset with an `externalId` the second
18+
pass is idempotent and the cost is doubled work; for a `mode: 'insert'` dataset
19+
it is the dataset APPLIED TWICE per boot — measured here as two `insert` calls
20+
for one record.
21+
22+
The legacy read now carries the same reference guard its sibling collector has
23+
always carried: `loadTranslations()` performs the identical two-location read
24+
and skips the legacy half when `manifest.translations` IS the array the top
25+
level already contributed. That asymmetry between the two collectors was the
26+
whole defect, so the repair is the sibling's guard rather than a third spelling
27+
of the same idea.
28+
29+
⛔ Not a removal of the legacy read: a bundle whose `manifest.data` is a
30+
genuinely different array from its top-level `data` still contributes both, and
31+
a bundle that nests its manifest is unaffected either way. Nothing is added to
32+
or removed from any published surface.
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
"@objectstack/spec": minor
3+
---
4+
5+
The model-facing solution-blueprint mirror can no longer generate an identifier the applier rejects.
6+
7+
`SolutionBlueprintSchema` (what `apply_blueprint` validates against) and `SolutionBlueprintStrictSchema` (the OpenAI-strict structured-output contract the design model generates against) are two declarations of one shape. Their KEYS were pinned by an existing parity test; their VALUES had never been. Every identifier in the lenient schema carried `.regex(/^[a-z_][a-z0-9_]*$/)` and not one identifier in the strict mirror carried it — 20 leaves apart, measured.
8+
9+
The consequence was a build whose approval did nothing. Asked for a CRM, the design model emitted a `company_size` select whose option values came straight off the labels — `1_49` for 「1-49人」. Generating that was legal. Applying it was not: on the turn the user clicked 「确认,开始搭建」 the deterministic confirm replay handed that exact blueprint to `apply_blueprint`, which refused it wholesale (`objects.0.fields.2.options.0.value: Invalid string: must match pattern /^[a-z_][a-z0-9_]*$/`) and staged nothing. The app appeared only because the model noticed the error card and retried with a repaired blueprint the user had never seen.
10+
11+
Every identifier leaf in the strict mirror now carries the same `SNAKE_CASE` constraint the lenient schema enforces — object / field / view / dashboard / widget / app / nav names, `reference`, `nameField`, `columns`, `groupBy`, `measure`, roll-up `object` / `field` / `relationshipField`, condition `field`, and select option `value`. The constraint is emitted into the JSON Schema the model is given (`pattern`), so an out-of-pattern identifier is refused at generation instead of after approval. Option `value` additionally spells out the case that produced the incident: it may never start with a digit, so 「1-49人」 is authored as `size_1_49` — the `label` keeps the human wording untouched, and only the stored value is an identifier.
12+
13+
A new `strict mirror ↔ lenient schema — VALUE parity` test walks both schemas leaf by leaf and fails on any future divergence, the value-side twin of the key-parity gate that already guards this pair.
14+
15+
Refs cloud#1967.
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
"@objectstack/spec": minor
3+
---
4+
5+
feat(spec)!: a text operator over a field whose DECLARED type can never store a string is refused at the engine's field-aware door — the contract rows (#15661)
6+
7+
<!-- adr-0087: not-required (no-migration-prescription) nothing is renamed, retired or respelled: no metadata key, export or operator changes shape, so `objectstack migrate meta` has nothing to rewrite; an author's remedy is to drop or retarget the mistaken filter condition, which is a change to the query they meant, not to a spelling -->
8+
9+
**BREAKING** accept-set narrowing, declared here and enforced at the engine door: a text operator (`$contains` / `$notContains` / `$startsWith` / `$endsWith` / `$icontains` / `$like` / `$ilike`) over a field whose DECLARED type is numeric, boolean, temporal (`date` / `datetime` / `time`) or structured JSON is refused before any driver runs — `INVALID_FILTER` / 400, naming the field and its declared type — instead of answering `[]` or a dialect accident. Shipped as `minor` under the repo's launch-window convention for breaking changes. Maintainer ruling 2026-09-05 on #15661 (director decision batch #43, verbatim 「同意」): option C-deny.
10+
11+
The refused set is the union of six EXISTING classes in `field-value.zod.ts`, by reference — `NUMERIC_VALUE_TYPES``BOOLEAN_VALUE_TYPES``CALENDAR_DATE_TYPES``INSTANT_TYPES``CLOCK_TIME_TYPES``STRUCTURED_JSON_TYPES` — so no new vocabulary is minted and a member added to one of those sets later is refused without a change here. String-valued classes pass: `STRING_VALUE_TYPES`, `autonumber`, the option-code classes (single and multi — `tags` included), the record-id classes, and the file classes. `formula` is judged as the field type its declared `returnType` names (`text` passes; `number` / `boolean` / `date` are refused) and is deferred — not judged — when `returnType` is absent. A dotted path into a structured-JSON field stays unjudged, as `filter-dotted-head` already declares.
12+
13+
New on `@objectstack/spec/data` (`filter-text-operator-declared-type.ts`): `TEXT_FILTER_OPERATORS` (pinned equal to `StringOperatorSchema`'s keys), `TEXT_OPERATOR_DOOR_REFUSED_TYPES` / `TEXT_OPERATOR_DOOR_PASSING_TYPES`, `FORMULA_RETURN_TYPE_AS_FIELD_TYPE`, the pure verdict `textOperatorDoorVerdict`, the class table `TEXT_OPERATOR_DOOR_TYPE_CLASSES` (every `FieldType` member exactly once — pinned as a census), the fixture object `TEXT_OPERATOR_DOOR_FIXTURE`, and the derived case table `TEXT_OPERATOR_DOOR_CASES` the engine suite consumes.
14+
15+
The door itself lands in `@objectstack/objectql` under its own engine-lane card (beside the `INVALID_FIELD` unknown-field door, judged against the object's real field map, before any driver dispatch); this changeset is the contract half. Beneath the door nothing moves: a direct driver call — and every evaluator no door fronts — keeps answering `FILTER_TEXT_CASES`' stored-value row (#14079), and the SQL faces' compile-time type-gate set `NON_TEXT_STORED_VALUE_TYPES` stays numeric + boolean, deliberately narrower than the door's set.
16+
17+
What an author sees after the door lands: a condition such as `{ amount: { $contains: '5' } }` over a `number` field, which used to answer an empty list with no signal, is refused with a message naming `amount`, `number` and `$contains`. The condition was a mistake in every measured occurrence (a substring over a number can never match); drop it, or aim it at the text field that was meant.
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
"@objectstack/runtime": patch
3+
---
4+
5+
The package-publish seed read-back no longer runs a two-attempt org-then-env ladder whose rungs resolve the same row.
6+
7+
`applyPublishedSeeds` — the route-level seed apply behind `POST /packages/:id/publish-drafts`, which runs for protocols that do not self-apply seeds inside `publishPackageDrafts` — read each just-published `seed` body twice when the session had an active organization: once naming the organization, then once env-wide. The comment above it said the first attempt tried the active org and the second fell back, "and resolving the wrong scope here is what silently produced `0 rows loaded`".
8+
9+
That was true when it was written and is not true now. `seed` declares `allowOrgOverride: false`, and `getMetaItem` resolves `organizationIdForMetaRead(request.type, request.organizationId)` once at its top and spends that binding — never the raw argument — on every read beneath it. The predicate answers `undefined` for every non-overridable type, so both rungs asked the engine the same predicates and served the same answer. Measured rather than reasoned: against the shipping protocol over one store, the two requests produce byte-identical engine reads and byte-identical answers on both the hit and the miss branch, and neutering the second rung reddens nothing on a pinned publish-then-read path (a `view` control confirms the same comparison does separate the two rungs for an org-overridable type).
10+
11+
The read is now a single call naming no organization, and the comment states that the scope is decided by the registry flag and the gate inside `getMetaItem` rather than by this call site — matching the sentence the `app` flip in the same file already carries.
12+
13+
One observable changes, and only on the failure branch: `getMetaItem` answers a wrapper rather than a falsy value for a name it cannot resolve, so the second rung was in practice reached only when the read *threw* — where it repeated the identical failing read and appended the same sentence to the client-facing `seedApplied.errors[]` twice. A failed read-back is now reported once. Nothing about which row a publish resolves, or whether its rows load, moves.
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
'@objectstack/core': patch
3+
---
4+
5+
A session whose active organization is no longer one the user belongs to now resolves with no active organization instead of that one's data.
6+
7+
Under a wall-enforcing tenancy posture (`isolated` / `group`), `resolveAuthzContext` took a browser session's stored `activeOrganizationId` as the request tenant without ever comparing it to the user's current memberships — the framework's only such comparison was gated on an API-key principal. A session whose owner had been removed from an organization therefore kept reading that organization's rows and writing into it until the session expired on its own (7 days by default), including when the removal went through the product's own offboarding path.
8+
9+
That claim is now vetted: if it is not in the caller's `accessible_org_ids`, it is dropped and the context resolves with no active organization at all, which the tenant wall already fails closed on (reads resolve to nothing; a tenant-scoped write is refused by ADR-0123 D2). The principal is **not** refused — a session is a person who may hold memberships elsewhere, so they stay signed in and can switch to an organization they are actually in. The API-key arm is unchanged: a key is its organization binding and is still refused outright. The wire is unchanged; the drop is reported to the operator as a single server-side `warn`.
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
"@objectstack/spec": minor
3+
"@objectstack/service-automation": minor
4+
"@objectstack/lint": minor
5+
---
6+
7+
A flow condition that is neither CEL text nor an expression is now refused at build time, instead of being read as an empty condition and answering a silent `false`.
8+
9+
`evaluateCondition` derives its source as `typeof expression === 'string' ? expression : (expression?.source ?? '')`. For a value that is neither — a number, a boolean, an array — the read yields `undefined`, the `??` supplies `''`, and the empty-source arm returns **`false`**: the "an unauthored branch must not open" rule, applied to a value that was very much authored. Measured: a `decision` node carrying `config: { condition: 42 }` **registered clean** and executed `success: true` with nothing said at any layer; `{ source: 1 }` did not even get that far and threw a bare `TypeError: exprStr.trim is not a function` out of the validator. `config.condition` is also the key a **start node's trigger gate** is read from, so the same value could gate a whole flow shut forever with no signal to the author.
10+
11+
- The new `structuralConditionRefusal` / `STRUCTURAL_CONDITION_SHAPE_REFUSAL` in `@objectstack/spec/automation` are the single shared notion of why, read by both validators so build time and author time cannot disagree about the shape. `registerFlow` throws, naming the node or edge and attributing the finding; `objectstack validate` reports the same refusal as a located `error`.
12+
13+
**This is deliberately NOT the `predicate`-slot rule, and the difference is measured.** A ledger `predicate` slot (`decision.conditions[].expression`, a screen field's `visibleWhen`) is declared `z.string()`, so `PREDICATE_SLOT_STRING_REFUSAL` refuses every non-string including an envelope. Neither structural slot is declared that way: `FlowEdgeSchema.condition` is `ExpressionInputSchema`, whose string arm **transforms into** `{ dialect: 'cel', source }` — so after `FlowSchema.parse` every authored edge condition *is* an envelope — and `FlowNodeSchema.config` is an open `z.record` that passes an envelope written at `config.condition` through verbatim, where `evaluateCondition` evaluates it correctly. Both shapes stay accepted here; an envelope with no `dialect`, and an `ast`-carrying one (`ExpressionSchema`'s own `source`-or-`ast` rule), stay accepted too.
14+
15+
**Strings are untouched, deliberately.** A whitespace-only condition still means "not authored" and still answers `false` on both sides — consistent behaviour, ruled correct, not a defect. What a non-empty string *says* is still `validateExpression('predicate', …)`'s verdict, brace trap and all. Only the shape moved.
16+
17+
An app that authored a number, a boolean, an array or a source-less object in a node or edge `condition` now fails to register with a message naming the site; the fix is to write the condition as bare CEL text (`record.rating >= 4`) or as an expression envelope.
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.

0 commit comments

Comments
 (0)