Skip to content

Commit 7126641

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-14945-flow-end-node-refused-outcome
2 parents b9ea7d4 + 8e7653b commit 7126641

136 files changed

Lines changed: 13396 additions & 745 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: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
"@objectstack/service-analytics": minor
3+
"@objectstack/driver-sql": minor
4+
---
5+
6+
The analytics SQL compilers compile the case-sensitive text family per dialect, so a `$contains` policy on SQLite stops admitting rows it excludes (#15684)
7+
8+
`$contains` / `$notContains` / `$startsWith` / `$endsWith` are case-SENSITIVE on every backend (#4706 Q2 = A). All three of `service-analytics`' SQL compilers emitted `col LIKE ? ESCAPE ?` on every dialect, and SQLite's `LIKE` folds ASCII case unconditionally — the fold cannot be turned off per statement, because `PRAGMA case_sensitive_like` is a connection-global switch. Measured on sql.js over the shared `FILTER_TEXT_ROWS` fixture, `{ name: { $contains: 'acme' } }` answered `['1','2']``ACME Corp` **and** `acme corp` — where `FILTER_TEXT_CASES` says `['2']`.
9+
10+
On two of the three compilers that is a wrong chart. The third is `read-scope-sql.ts`, the ADR-0021 D-C read scope: a scope that **admits** rows the policy's case-sensitive predicate excludes is over-reach, not a loose filter — the same reading that file already applied to its own `LIKE` escaping. The `/analytics/sql` echo was wrong in a third way: it printed `LIKE` while the statement it claims to reproduce ran through a driver that has emitted `GLOB` on the SQLite dialects since #6518.
11+
12+
What changed:
13+
14+
- **The construct is chosen per dialect** (`text-match-sql.ts`), arm for arm with `driver-sql`'s own table: `GLOB` on SQLite (case-exact by definition, with its own `*` / `?` / `[` escaped class and no `ESCAPE` clause), `LIKE` over `CAST(… AS BINARY)` on MySQL, and `LIKE` **unchanged** on Postgres, where it is already exactly the ruled semantics. There is no single construct that is case-exact and parses on all three, so the dialect had to become an input rather than a guess.
15+
- **The dialect arrives from the driver that will execute the statement.** New optional `AnalyticsServiceConfig.sqlDialect`, wired by `AnalyticsServicePlugin` from `IDataEngine.getDriverForObject`. `SqlDriver.dialectName` is now public so that answer can be read without a second dialect-resolution table drifting behind the driver's own knex spellings; it is derived and read-only.
16+
- **A host that answers no dialect keeps the `LIKE` it always got** — "cannot answer, do not block". Postgres deployments see byte-identical SQL.
17+
18+
`$icontains` is untouched: it keeps its own ASCII-only fold on both sides, and collapsing the two families onto one path would hand the case-exact family back the fold the ruling took away from it. `LIKE` escaping is unchanged wherever a `LIKE` is still emitted.
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: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
---
2+
"@objectstack/objectql": patch
3+
---
4+
5+
fix(objectql): a published `BulkDataEvent` now names the ONE organization the tenant wall named for the batch
6+
7+
`BulkDataEventSchema.organizationId` (`@objectstack/spec/api`, declared by the
8+
contract half) is one organization for a whole predicate write, or absent. The
9+
only bulk producer — `publishBulkDataEvent`, behind the `multi: true` branches
10+
of `update()` / `delete()` — never set it, so every `data.records.updated` /
11+
`data.records.deleted` event read "not asserted" and a tenant-scoped consumer
12+
could deliver nothing per organization on the bulk path. This is the bulk half
13+
of the cross-tenant webhook fan-out leak; the single-record half (`DataEvent`)
14+
landed separately.
15+
16+
The producer now stamps the key from what it already holds — no second query
17+
on the publish path: under `isolated` the caller's active organization (the
18+
Layer 0 wall's equality term), under `group` the caller's membership set when
19+
it names exactly one organization. It is OMITTED — never the caller's active
20+
organization standing in — on a `single`-posture deployment, on an `isSystem`
21+
context (no wall composed), on a multi-membership `group` sweep, when no
22+
enforcement layer injected a posture (the `OS_TENANCY_POSTURE` env fallback is
23+
deliberately not consulted), when the caller may have crossed the wall as a
24+
`PLATFORM_ADMIN` or carries no resolved posture rung, and on an object the wall
25+
does not key on. `absent` here means "the producer did not assert one
26+
organization for the batch", deliberately NOT the `DataEvent` reading
27+
"belongs to no organization".
28+
29+
Which objects "the wall does not key on", stated exactly rather than claimed as
30+
a mirror: plugin-security's Layer 0 composes no wall when its `tenancyDisabled`
31+
input is true or the object carries no `organization_id`, and it folds THREE
32+
clauses into `tenancyDisabled``tenancy.enabled === false`,
33+
`systemFields.tenant === false`, and the deployment's `platformGlobalObjects`
34+
carve-out. The producer reads the registry's binding of that predicate
35+
(`carriesTenantScopeColumn`: the first two clauses plus the column clause) and
36+
answers absent on a federated (`external`) object; a custom
37+
`tenancy.tenantField` is therefore not an exit by itself — the object is walled
38+
iff it carries `organization_id`, and the key follows the wall. The third
39+
clause is deployment-declared and not readable by the engine: a
40+
deployment-exempted object under an armed wall is still stamped with the
41+
caller's organization by this producer alone, and that population's exact
42+
answer is decided by the seam ruled on in #15706.
43+
44+
`patch`, not `minor`: the act adds no member to this package's published
45+
surface. `carriesTenantScopeColumn` is exported at module level inside
46+
`registry.ts` only — `@objectstack/objectql`'s entries (`.`, `./core`) re-export
47+
named members and never `export *`, so `dist/index.d.ts`, `dist/core.d.ts` and
48+
both entries' runtime export lists are unchanged (measured on the built `dist`,
49+
with a firing control) — and the emitted event's member was declared, typed
50+
and paid for at `minor` by the spec half. Producer conformance to an existing
51+
optional member under `fix(` changes no public surface of this package.
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.

.changeset/great-clouds-repair.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
'@objectstack/plugin-hono-server': patch
3+
---
4+
5+
`GET /auth/me/localization` answers the deployment's resolved `currency` and `timezone` instead of `null`
6+
7+
The handler read both off the request `ExecutionContext`, citing ADR-0053, but the resolver serving this surface is a hand-rolled envelope that never carried them — so every authenticated caller was answered `currency: null, timezone: null` whatever the `localization` settings said, and the console's regional-formatting seed was fed nulls. All three values now come from one reading of the same `resolveLocalizationContext` cascade the dispatcher's shared assembler uses. `locale` resolution is unchanged. `timezone` now always answers (cascade floor `UTC`); `currency` still answers `null` when the deployment configures none — that value has no floor.
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
---
2+
"@objectstack/rest": patch
3+
---
4+
5+
fix(rest): metadata label lookup honours the stack's declared `i18n.fallbackLocale` / `defaultLocale` instead of falling through to the `en` bundle (#14882)
6+
7+
On a workspace whose labels are authored in `zh-CN` (`defaultLocale: 'zh-CN'`,
8+
`fallbackLocale: 'zh-CN'`) and which ships only a courtesy `en` translation bundle,
9+
`GET /api/v1/meta/object/:name`, the `/meta/:type` list, `GET /api/v1/meta` and the
10+
public-form schema served the ENGLISH bundle labels to a `zh-CN` request (`Entry Sheet`
11+
for an authored `填报单`, `KPI Assessment` for `KPI 考核管理`). The document translators walk
12+
`requested locale → fallback chain → authored label` and default the chain to a literal
13+
`['en']`; every REST seam passed none, so the declared fallback never reached the chain
14+
and `en` was consulted before the authored label.
15+
16+
Every metadata translation seam now passes `fallbackChain: [i18n.getFallbackLocale()]`
17+
the locale the i18n service's own `t()` falls back to, which `I18nServicePlugin` receives
18+
from the stack config as `fallbackLocale || defaultLocale || 'en'`. For the workspace
19+
above a `zh-CN` request now resolves `zh-CN → zh-CN → authored label` (the authored
20+
Chinese labels), an `en` request still gets the `en` bundle, and a `zh-CN` bundle, when one
21+
is shipped, still wins over the authored label.
22+
23+
Feature-detected: an i18n service that does not declare a fallback (the method is
24+
optional on `II18nService`; the core in-memory fallback has none) gets no chain and the
25+
resolver's own default applies exactly as before. A stack declaring `defaultLocale: 'zh-CN'`
26+
with `fallbackLocale: 'en'` is likewise unchanged — the declared `en` is honoured as it
27+
reads.
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
"@objectstack/service-i18n": minor
3+
---
4+
5+
feat(service-i18n): `FileI18nAdapter.getFallbackLocale()` reports the `fallbackLocale` the adapter was constructed with (#14882)
6+
7+
Implements the new optional `II18nService.getFallbackLocale()`. `I18nServicePlugin`
8+
already receives `fallbackLocale || defaultLocale || 'en'` from the stack's `i18n`
9+
config on both boot paths (`os serve`, the dev plugin); this makes that declaration
10+
readable, so the REST metadata reads pass the document translators the same fallback
11+
locale `t()` itself consults. Returns `undefined` when no `fallbackLocale` was given.
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
---
2+
"@objectstack/spec": minor
3+
---
4+
5+
feat(spec): `II18nService.getFallbackLocale()` — the declared fallback locale is readable, so the metadata-document translators can be handed the chain the deployment declared (#14882)
6+
7+
`ResolveOptions.fallbackChain` on the `@objectstack/spec/system` label
8+
resolvers (`translateMetadataDocument`, `translateObject`, `translateApp`,
9+
`resolveViewLabel`, …) is the ordered list of locales consulted after the
10+
requested one and BEFORE the authored label. Nothing on `II18nService`
11+
exposed the deployment's declared fallback (`i18n.fallbackLocale`, else
12+
`defaultLocale`), so no serving layer could thread it, and every caller fell
13+
to the resolver's literal `['en']` default. A `zh-CN` workspace that shipped a
14+
courtesy `en` bundle therefore served English bundle text to a `zh-CN`
15+
request ahead of its own authored Chinese labels.
16+
17+
- New optional contract member `II18nService.getFallbackLocale?(): string | undefined`
18+
— the locale the service's own `t()` consults second. `undefined` (or the
19+
method absent) means nothing was declared, and a serving layer must then
20+
leave the resolver's default in place rather than invent a chain.
21+
- The `fallbackChain` documentation now states who supplies it (the serving
22+
layer, from `getFallbackLocale()`) and that the `['en']` default applies
23+
only when a caller declares no chain at all. The resolver's behaviour for
24+
a caller that passes nothing is unchanged.
25+
26+
Additive: no existing implementation or caller changes shape.
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
"@objectstack/spec": patch
3+
---
4+
5+
The tenant-scope and owning-business-unit system columns now render a localised display name on the `/meta` read exits, as the other platform-injected columns already did.
6+
7+
`translateObject` carries a built-in label table for the columns the platform injects onto every eligible object, applied while a column still carries its injected English default, so a `zh-CN` / `ja-JP` / `es-ES` request never sees the English label on a custom object that ships no translation entries of its own. The table covered `owner_id`, `created_at`, `created_by`, `updated_at` and `updated_by` but not the two remaining injected columns, `organization_id` (`Organization`) and `owning_business_unit_id` (`Owning Business Unit`), so those two leaked English on every locale. Both rows are added, with the wording the platform bundles already use for the same columns on platform objects. The identity-stable column definitions are untouched, no new authorable key is introduced, and a label a tenant or author customised is still never overridden.

0 commit comments

Comments
 (0)