Skip to content

Commit 3ef5e6c

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-15705-screen-headless
2 parents 60fdd31 + 3508869 commit 3ef5e6c

104 files changed

Lines changed: 7826 additions & 684 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: 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: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
---
2+
"@objectstack/spec": minor
3+
---
4+
5+
feat(spec)!: `FlowSchema` refuses a flow whose `edges[]` declares the same id twice (#14964)
6+
7+
<!-- adr-0087: not-required (no-migration-prescription) No authorable key is renamed, retired or re-typed: `edges[].id` keeps its name, its type and its describe, and every flow whose edge ids are unique parses byte-identically. The only newly refused shape is two edges sharing one id — a collision, not a spelling — and its remedy is to renumber one of the two, which is authoring intent no `objectstack migrate meta` rewrite can choose for the author. The Zone-2 census over this repo (776 `edges[]` arrays, 1,098 edges under `packages/**` and `examples/**`, with a lit control) found zero instances, so there is no in-repo file to name. -->
8+
9+
**BREAKING** accept-set narrowing on `FlowSchema` — a flow whose `edges[]`
10+
carries two edges with the same `id` is now **refused at parse time** — by
11+
`FlowSchema.parse` / `safeParse`, `defineFlow`, and every door that validates a
12+
flow through the schema (`objectstack validate`, the runtime publish gate, a
13+
stack's `flows[]`) — where it used to parse on green. Shipped as `minor` under
14+
the repo's launch-window convention for breaking changes. Maintainer ruling
15+
2026-09-05 on #14964 (director decision batch #40, verbatim 「同意」): option
16+
A — an `error`, not a `warning`; no opt-out, no transition window.
17+
18+
Every reader of an edge id assumes the ids in a flow are unique — a designer,
19+
a BPMN export, a flow diff, any traversal that dedupes by id — and nothing
20+
enforced it. A real duplicate (`id: 'e20'` on two edges of one flow) shipped
21+
through two releases of green CI in a downstream app and was inert only
22+
because the engine keys out-edges by `source`, never by `id`: the collision is
23+
invisible until something keys on ids, and then silently wrong rather than
24+
loudly broken. The id space is hand-authored, so the next author picking a
25+
"free" id from the sequence had no way to know it was taken.
26+
27+
**What changes** (`packages/spec/src/automation/flow.zod.ts`): a `superRefine`
28+
on the flow's `edges[]`. Each later occurrence of an already-declared id raises
29+
one `custom` issue, anchored at `edges[N].id` of the *later* edge and naming
30+
both positions, so the formatted error points at the edge to renumber:
31+
32+
```text
33+
✗ edges.7.id: Duplicate edge id `e20` — `edges[7]` reuses the id already declared by `edges[3]`; every edge id in a flow must be unique. Renumber one of them: …
34+
```
35+
36+
**What does NOT change:** `edges[].id` keeps its name, type and describe; the
37+
node vocabulary, the edge `type` enum and every other refusal are untouched;
38+
a flow with unique edge ids (or no edges) parses exactly as before. Node ids
39+
are not covered by this change.
40+
41+
The shape that is refused, and what the author does about it — a two-edge
42+
excerpt, the later edge renumbered:
43+
44+
```ts
45+
// before — parsed on green, both edges keyed 'e20'
46+
edges: [
47+
{ id: 'e20', source: 'qualify', target: 'convert' },
48+
{ id: 'e20', source: 'convert', target: 'end' },
49+
]
50+
51+
// after — refused at parse (edges.1.id: Duplicate edge id `e20` …); renumber the later one:
52+
edges: [
53+
{ id: 'e20', source: 'qualify', target: 'convert' },
54+
{ id: 'e21', source: 'convert', target: 'end' },
55+
]
56+
```
57+
58+
**Remedy.** Renumber the later edge to an id no other edge in that flow
59+
carries; nothing else in the flow needs to move. The census over this
60+
repository found no flow to migrate, so this is a release note, not a
61+
migration: no shipped example, fixture or seed in `packages/**` or
62+
`examples/**` declares a duplicate edge id, and the pinned objectui tree
63+
carries none in its authored flows. The one known downstream instance was
64+
renumbered before this change (hotcrm PR #1571).

.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.
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
"@objectstack/lint": patch
3+
---
4+
5+
No authoring rule throws on a non-record entry of any stack collection.
6+
7+
A collection is authored either as a list or as a name-keyed map, so every rule that reads one coerces `unknown` into an array of records first. That coercion had been hand-copied into 39 modules, and 23 of the copies spelled the array branch as an unchecked cast — every member was asserted to be a record. A YAML list item left empty deserialises to `null`, so a single stray `-` under `flows:`, `pages:`, `dashboards:`, `datasets:`, `apps:`, `permissions:`, `capabilities:`, `data:`, `hooks:`, `views:`, `actions:`, `translations:` (or a per-object `fields:` / `actions:` / `views:`) reached a property read on `null` and threw a stack trace out of `os lint` / `os validate` instead of reporting a finding. The rules are pure `(stack) => Finding[]` running on the raw path, so nothing upstream had judged the entry's shape.
8+
9+
Twenty-two of those readers now read through the shared, guarded `recordsOf`, which drops a non-record member of the array shape whole and keeps the author's key on the map shape. Nothing else about what the rules judge changes: a valid entry standing beside a junk one is still read, and still draws exactly the findings it drew before.
10+
11+
The remaining copies are pinned by a new source-text test in the package, so the predicate cannot be pasted back in: it asserts that `recordsOf` is the only collection coercion, that every module still holding a private one is named in a dated ledger that is exact in both directions, and that no coercion outside a dated single-file allowance casts its array branch unchecked.
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
---
2+
"@objectstack/lint": patch
3+
---
4+
5+
`chart-axis-not-selected` resolves a report chart against its own `chart.yAxis`, not `report.values` (#15734)
6+
7+
**Behaviour change — one false finding removed on the report surface.** A report chart whose `chart.yAxis` names a declared measure that `report.values` does not select no longer raises a `chart-axis-not-selected` warning. Nothing else about the rule moves, and no other surface moves at all.
8+
9+
The warning stated a query consequence the renderer refutes. Read at the `@object-ui` revision this repo pins (`.objectui-sha`), `plugin-report/src/DatasetReportRenderer.tsx` does not query `report.values` for the chart at all — it runs the chart's own, narrower query out of the two axis strings:
10+
11+
```
12+
const state = useDatasetRows(
13+
dataset,
14+
plan.kind === 'series' && xAxis ? [xAxis] : [],
15+
wantsQuery && yAxis ? [yAxis] : [],
16+
```
17+
18+
and says so in that file's own words at the `scopeOrder` docblock: *"the embedded chart queries only `chart.xAxis` × `chart.yAxis`"*. So the measure the warning said "the query does not return" is exactly the one the query asks for, and the chart plots it. `report.values` is the selection of the TABLE beneath the chart.
19+
20+
Both limbs follow from that one measurement:
21+
22+
- **No not-selected check at the report `chart.yAxis`.** That position IS the chart's query, so it cannot fail to select itself. `chart-measure-unknown` there is untouched: an UNDECLARED measure is still no column at all, and still an `error`.
23+
- **`chart.series[].name` resolves against the singleton `{ chart.yAxis }`.** The entry is a display-name override paired with a DERIVED series, and the chart derives exactly one (`buildChartSeries(…, [xAxis], [yAxis], …)`). An entry naming `chart.yAxis` now lands however the table is selected, and one naming any other declared measure is still reported — including a measure `report.values` does select, which it could not reach before.
24+
25+
The list-view and page-component surfaces are unchanged, and carry firing controls that say so: on both, `values` IS the measure set the query asks for (`ObjectView` hands it to the chart; `ObjectChart` queries `{ dimensions: schema.dimensions, measures: schema.values }`), so the existing resolution is the right one there.
26+
27+
The per-position tier and consequence wording is untouched — only the SET the report surface resolves against moves.

0 commit comments

Comments
 (0)