Skip to content

Commit 2e33c87

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-14653-i18n-walk-parity-gate
2 parents 856fac6 + 7bc5d37 commit 2e33c87

844 files changed

Lines changed: 70793 additions & 6035 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: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
---
2+
"@objectstack/spec": patch
3+
---
4+
5+
fix(spec): `ActionEngineFacade.find` declares its second parameter as a FILTER, not an ObjectQL envelope (#14175)
6+
7+
`find(object, query: Record<string, unknown>)` documented nothing, and its
8+
parameter carried the name of the envelope every other read on the platform
9+
takes. The runtime (`buildActionEngineFacade`,
10+
`packages/runtime/src/action-execution.ts`) treats the argument as the bare
11+
`where` half — wrapping a non-empty one as `{ where: filter }` and passing
12+
`{}` through unwrapped — so a handler that passed the envelope got
13+
`{ where: { where: … } }`, matched nothing and returned `[]` with no error,
14+
while its one unfiltered read kept working. A hand-written test double built
15+
on the same belief passed every assertion; an application's headline action
16+
was a silent no-op for its whole life under a green suite.
17+
18+
The member is now `find(object, filter: FilterCondition)` — the published
19+
`QueryAST.where` type — with a doc comment stating the contract, the runtime's
20+
wrap, and both limbs (envelope wrapped; empty passed through); the facade
21+
docblock points at it. The parameter's TYPE now says what the runtime does
22+
at the one place a handler author reads.
23+
24+
Compile-layer signal only, shipped as `patch` (the #12615 precedent — a
25+
compile-time narrowing with no change in what parses or runs): no runtime
26+
behaviour changes, nothing changes in what the facade accepts or returns, and
27+
the narrowing bites only a primitive or a mistyped `$and` / `$or` / `$not`.
28+
⚠️ It does NOT refuse `{ where: … }` at compile time — `FilterCondition`'s
29+
string index signature admits `where` as a field name — so the compile-time
30+
bar is partial and the doc comment is the contract of record. An
31+
implementation typed with the old `Record<string, unknown>` still satisfies
32+
the interface (method parameters are bivariant), so nothing constructing the
33+
facade changes.
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
---
2+
"@objectstack/spec": patch
3+
---
4+
5+
fix(spec): `ActionSchema.method`'s worked example now names the shipped data door
6+
7+
The `method` docblock's only worked example of a `type: 'api'` PATCH pointed at
8+
`/api/v1/sys_api_key/{id}` — a path the router never mounts. The shipped data
9+
door composes as `getApiBasePath()` + `crud.dataPrefix` + `/:object/:id`, so the
10+
update endpoint is `PATCH /api/v1/data/:object/:id` on a default host. The `/data`
11+
segment was missing, and nothing catches the difference at authoring time:
12+
`objectstack validate` does not check `target`, `type: 'api'` has no author-time
13+
route validation, and the action parses green, renders, is clickable, and 404s at
14+
the click — the same silent shape as an unregistered handler, arriving through a
15+
doc example. This mattered more than an ordinary stale comment because it is the
16+
one worked example of that route on the published contract.
17+
18+
The example now reads `/api/v1/data/sys_api_key/${ctx.recordId}`. The object name
19+
is unchanged on purpose — the error was the path STRUCTURE, not which object the
20+
example picks — and the id is spelled with the `${ctx.X}` interpolation that
21+
`target`'s own docblock documents, so the two placeholder conventions in this one
22+
schema stop reading as interchangeable (a bare `{recordId}` is `newTabUrl`'s
23+
convention alone).
24+
25+
Two things the corrected example now says that the old one did not:
26+
27+
- **The full path is host-dependent.** Under `enableProjectScoping` with
28+
`projectResolution: 'required'` only
29+
`/api/v1/environments/:environmentId/data/:object/:id` is registered, so even a
30+
correctly spelled unscoped path still 404s on such a host. An author copying a
31+
full path needs to know which base their host mounts.
32+
- **A single-record field write has a declarative form now.** `operation: 'update'`
33+
with `patch` writes the current record on the data plane as the caller, with no
34+
endpoint, method or id placeholder to spell. The `type: 'api'` + `PATCH` form
35+
remains the way to call an explicit endpoint.
36+
37+
Documentation only: no schema member, no `.describe()` and no runtime behaviour
38+
changes. It is a `patch` rather than a `skip-changeset` because the corrected text
39+
publishes — `@objectstack/spec` ships `dist/**` and `src/**/*.zod.ts`, and both
40+
carry this docblock.
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
---
2+
'@objectstack/spec': minor
3+
---
4+
5+
feat(spec): a row action gets the declarative single-record field write — `operation: 'update'` + `patch` (#14092)
6+
7+
The most common action in any app — set a field on the current record — had no declarative form for a ROW action while the BULK form was fully declarative: a list view's `bulkActionDefs` `{ operation: 'update', patch, visible }` runs on the data plane under the caller's own permissions with hooks and validations firing, and the identical intent on one row had to be a hand-written, system-elevated handler that re-established that authorization by hand. Maintainer ruling 2026-09-01: the row action gets the bulk def's declarative counterpart.
8+
9+
`ActionSchema` now accepts, mirroring the bulk vocabulary word for word and inventing no second spelling:
10+
11+
- `operation: 'update'` — the declarative single-record field write. One member by ruling; `'delete'` and `'custom'` are refused with the reason (a row delete is the object's own affordance; `'custom'` means "dispatch the action this def names", and on a row the action is already the action).
12+
- `patch: Record<string, unknown>` — static field values written to the current record, merged UNDER the values `params` collects (a param of the same name wins). Passed through verbatim.
13+
- The action's existing `params`, `visible`, `confirmText` and `undoable` keys are reused, nothing duplicated; `undoable` now has its anchor — the patch names exactly the fields whose prior values are captured.
14+
15+
Key shape, pinned for contract review: `operation` is a parallel key beside `type`, not a new `ActionType` member. `type` stays at its default `'script'` — the platform action route, which is where the write is performed — and answers WHERE the action dispatches; `operation` answers WHAT the platform does there. Every contradiction is refused at its own path with a prescription: any other explicit `type`, `target`, `body`, `method`, `bodyExtra`, `bodyShape`, `recordIdParam`/`recordIdField`, `onSuccess`, `opensInNewTab`/`newTabUrl` beside `operation: 'update'`; `patch` without it; `operation: 'update'` with neither `patch` nor `params`; a `list_toolbar` location (no current record). `defineStack` refuses a standalone `operation: 'update'` action that names no `objectName` (an object-embedded one is bound by the object it is written on). An inline page-element action cannot carry the keys at all.
16+
17+
Executor contract for the downstream halves (runtime action dispatcher, objectui row-action executor — separate cards; both keys are `planned` in the liveness ledger until they land): a single-record data-plane update of the CURRENT record executed AS THE CALLER and never system-elevated, so the caller's object/row/field permissions, the object's hooks and its validations fire exactly as for a user edit; a caller who cannot read or write the row is refused; `undoable` captures the prior values of exactly the fields written.
18+
19+
Pure widening: nothing that parsed before stops parsing — `operation` and `patch` were unknown keys on this strict shape, and every new refusal is keyed on one of them.
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
---
2+
"@objectstack/platform-objects": patch
3+
"@objectstack/core": patch
4+
"@objectstack/objectql": patch
5+
"@objectstack/service-automation": patch
6+
---
7+
8+
fix(platform-objects,core): `sys_metadata_activation` ships tenant-less — drop the reserved organization column (#15024)
9+
10+
The ADR-0126 activation ledger records that **this environment** switched a
11+
packaged artifact off. That is deployment-level state, owned by no
12+
organization — so the table ships with no tenant column at all.
13+
14+
It briefly declared one: an `organization_id` marked "RESERVED", nullable, and
15+
written by nobody, held for a per-organization dimension ADR-0126 §5
16+
pre-charted. A reserved nullable tenant column is exactly the shape the
17+
total-organization-ownership record proposed in PR #14976 rules out, and this
18+
one had no reader either. **This is a plain removal, not a migration:** the
19+
table landed after the 17.2.0 tag, so no released version ever carried the
20+
column and no deployment has data in it. Should a per-organization dimension
21+
ever be wanted, it returns as a separate org-owned object — never as a column
22+
on this ledger.
23+
24+
What changed:
25+
26+
- **`sys_metadata_activation` declares `systemFields: { tenant: false }`** and
27+
no longer declares the column. Both halves are needed: the tenant anchor is
28+
INJECTED at registration, so deleting the field alone would have left the
29+
column exactly where it was. ⚠️ Deliberately NOT `tenancy: { enabled: false }`
30+
— that key is the ADR-0066 D2 platform-global *posture*, which the sibling
31+
`sys_sso_provider` uses for the opposite shape (a table that KEEPS its tenant
32+
column and needs the wall over it stood down). Here there is no column to
33+
wall. Both spellings reach `plugin-security`'s `tenancyDisabled`, which is
34+
required rather than incidental: a Layer 0 wall composing an equality on a
35+
column the table does not have denies every row.
36+
- **The declared unique index states `unique: 'global'`** over
37+
`(metadata_type, name)` instead of `'organization'`. ⚠️ The materialized DDL
38+
is unchanged: `normalizeDeclaredIndex` prepends the NULL-safe tenant key part
39+
only when the table HAS a tenant column, so `'organization'` already degraded
40+
to exactly these two columns. What changes is that the declaration now states
41+
the boundary it actually gets, rather than claiming a per-organization one
42+
that does not exist. Still explicit rather than bare `unique: true`, which
43+
lint `unique/unscoped-declared-index` warns on and protocol 18 rejects.
44+
- **`ObjectStoreMetadataActivationStore` drops its NULL filter and its
45+
org-row skip.** `list()` is now every activation row of its type, scoped by
46+
the `metadata_type` discriminator alone, and `setActive` takes the single row
47+
its keyed read returns instead of picking the NULL-organization one out of
48+
the result. Both guarded a column that no longer exists; the declared unique
49+
index over the two columns the lookup keys on is what makes that read
50+
single-valued. `ObjectStoreFlowActivationStore` and
51+
`ObjectStoreActionActivationStore` inherit the change.
52+
53+
Unchanged, and pinned: the operator gate on activation writes under walled
54+
postures (ADR-0126 D3), the `execute()`-time flow consult and the dispatch-time
55+
action consult, "absence of a row means ACTIVE", re-enabling UPDATES the row
56+
rather than deleting it, and a driver `0` reading as false. The pins that
57+
asserted the reserved column and the org-row skip are rewritten to pin the
58+
column's ABSENCE rather than deleted — including at the injection authority
59+
(`resolveInjectedSystemColumns`, which decides whether the column exists) and
60+
in a real booted stack, where the row's key set is a reading of the physical
61+
table.
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
---
2+
"@objectstack/types": patch
3+
---
4+
5+
fix(types): an aliased install (`"foo": "npm:bar@1"`) is now found by the host importer's ESM-only fallback
6+
7+
`createHostImporter`'s #14041 fallback finder verifies the one directory it
8+
consults — `<hostRoot>/node_modules/<key>` — by matching that directory's
9+
`package.json` `name` against the declared package name. An aliased install
10+
fails that check by construction: `{ "dependencies": { "foo": "npm:bar@1" } }`
11+
puts a manifest named `bar` at `node_modules/foo`. The finder answered
12+
`absent`, and an ESM-only aliased package therefore kept the pre-#14041 INSTALL
13+
wording — a confidently-wrong remedy sending an operator to run `pnpm install`
14+
against an install that is already correct, on a declaration shape
15+
`packageNameFromSpecifier`'s own documentation blesses.
16+
17+
The declaration is now parsed for the name it promises: `npm:bar@1`,
18+
`npm:@acme/x@^2` and the aliased `workspace:bar@*` name the package installed
19+
under the key, so that is the manifest name the finder expects there. An
20+
aliased ESM-only package is rescued exactly as a plain one is, and an aliased
21+
install publishing nothing loadable gets the message about the PACKAGE's own
22+
shape instead of the INSTALL message.
23+
24+
⚠️ The manifest-name check itself is NOT loosened — that check is what keeps
25+
the fallback strictly tighter than the CJS resolution it backs up (#4719's
26+
declaration gate, from the fallback side). What moved is the EXPECTATION, still
27+
authored by the host and still read out of the host's own `package.json`: an
28+
alias naming one package refuses a directory holding another, a non-aliased
29+
declaration is unchanged, and a value that is not a bare package name — a
30+
`workspace:` range, an alias carrying a subpath — yields no expectation to move
31+
to, so the key stays and today's refusal is kept. `link:` and `file:` name a
32+
LOCATION rather than a package, so no name is derivable from them at all; they
33+
keep the key expectation, and with it the conservative direction the finder had
34+
before.
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
---
2+
"@objectstack/spec": minor
3+
"@objectstack/service-analytics": minor
4+
---
5+
6+
feat(spec,analytics): `AnalyticsResult.fields[].builtinAggregate` — a closed discriminator for a measure column whose display name is the server's built-in default (#14492)
7+
8+
**What a consumer sees.** `queryDataset()` (and `POST /api/v1/analytics/dataset/query`,
9+
which relays the result verbatim) now carries an optional
10+
`fields[].builtinAggregate?: 'count' | 'sum' | 'avg' | 'min' | 'max' | 'count_distinct'`
11+
on a measure column. It is present exactly when the dataset measure behind the
12+
column declares an `aggregate` and **no** `label` — the producer then has nothing
13+
but the aggregate to name the column by, so it says which aggregate that is. It is
14+
absent whenever the author declared a label (a plain string or an inline locale
15+
map, even one with no entry for the request locale: an author's text is never
16+
re-labelled by a consumer), and absent on dimension columns and derived measures.
17+
The vocabulary is `AggregationFunction` (`data/query.zod.ts`), the one closed
18+
aggregate enum — no second spelling. `AnalyticsResultResponseSchema`
19+
(`api/analytics.zod.ts`) mirrors the member, refusing a spelling outside the enum.
20+
21+
**Why.** An AI-built dashboard's "count of customers by status" chart showed the
22+
English axis title "Count" on a Chinese UI. The renderer (objectui
23+
`buildChartSeries()` / `labelOf()`) treats `fields[].label` as resolved author
24+
content and passes it through verbatim — correctly, since a real custom label
25+
("Tasks") must survive. What it could not tell apart was an author's text from
26+
the server's built-in default for a bare `count`. Guessing from the label text
27+
was refused (it would catch an author who really named a field `Count`, and break
28+
the moment the default is spelled in another language); translating on the
29+
server was not taken (it copies the front end's language decision into the
30+
producer and leaves nothing for a per-widget override). The ruling (2026-09-02,
31+
option B) is a structured discriminator on the contract: the consumer prefers a
32+
locale lookup keyed by `builtinAggregate` — mirroring its existing
33+
`report.aggregate.*` keys — and falls back to `label`, then `name`.
34+
35+
**Producer-side changes.**
36+
37+
- `@objectstack/service-analytics``queryDataset`'s measure enrichment sets
38+
`builtinAggregate` from the dataset measure's own `aggregate` when the measure
39+
has no authored `label`. Judged on the authored key, never on the resolved
40+
string.
41+
- `@objectstack/spec` — the `dataset` create seed (`metadata-create-seeds.ts`)
42+
drops its hardcoded `label: 'Count'` from the seeded `count` measure, so a
43+
dataset created from Studio is a built-in default (wire: `builtinAggregate:
44+
'count'`) instead of an authored English literal. `getMeta()` for such a
45+
dataset now titles the metric by its name (`count`) rather than `Count`;
46+
`CubeMeta.measures[].type` already carried the aggregate there.
47+
48+
Purely additive: no key is removed or renamed, no authorable schema changes shape,
49+
and a consumer that ignores the member sees exactly the response it saw before.
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
---
2+
"@objectstack/spec": minor
3+
---
4+
5+
feat(spec): an `assignment` value may be a CEL value envelope — the expression ledger gains the `value` role
6+
7+
An assignment's whole job is to compute a value into a variable, yet CEL was
8+
reachable from flow metadata only where the answer had to be a boolean
9+
(`condition`, `decision.conditions[].expression`, a screen field's
10+
`visibleWhen`): `FLOW_NODE_EXPRESSION_PATHS` declared two roles, `predicate`
11+
and `flow-template`, and the `assignment` node's values were `{token}`
12+
interpolation only. So the stdlib the platform already declares, documents and
13+
tests — `joinNonEmpty` and the rest of `CEL_STDLIB_FUNCTIONS` — could not be
14+
called from metadata, and the commonest outbound shape a business application
15+
has (one digest message listing a recipient's N records) needed a `script`
16+
node.
17+
18+
Ruled (maintainer, 2026-09-02): the rendering half only, no new vocabulary.
19+
20+
- `FlowNodeExpressionRole` gains `'value'`: a slot whose authored value may be a
21+
`{ dialect: 'cel', source }` expression envelope evaluated by the expression
22+
engine to the value the variable takes — not a predicate, not a template.
23+
The ledger gains the `assignment` entry at `assignments.*`; ledger paths now
24+
accept a `*` segment ("every key of this object", the sibling of `[]`), and
25+
`resolveFlowNodeExpressions` emits only envelope-shaped objects for a `value`
26+
slot — a plain string there stays `{token}` interpolation. Every entry that
27+
existed before resolves byte-identically. `isExpressionEnvelopeShaped` is
28+
the exported recognizer both halves discriminate on.
29+
- `AssignmentConfigSchema` / `AssignmentValueSchema` /
30+
`AssignmentExpressionValueSchema` declare the `assignment` node's value
31+
contract: a string (`{token}` interpolation), a CEL value envelope (the
32+
`ExpressionSchema` spelling, narrowed to the `cel` dialect), or any other
33+
literal. Every value that parsed before still parses; the one newly refused
34+
shape is a malformed envelope (no `source`, an empty or non-string `source`,
35+
a `template` / `cron` / unknown dialect), refused at the variable's path with
36+
a fixed leading sentence. The map value carries `.meta({ xExpression:
37+
'value' })`, the declaration channel the ledger reads for this slot.
38+
39+
The executor half is a separate change in `@objectstack/service-automation`:
40+
until it lands, the built-in `assignment` executor still writes an envelope
41+
object into the variable verbatim and `notify` renders it as JSON, and the
42+
ledger's reconciliation ratchet there does not yet know the `value` marker.

0 commit comments

Comments
 (0)