Skip to content

Commit 12d3b8d

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/discussion-13564-a38847
2 parents 6976179 + ebb0822 commit 12d3b8d

971 files changed

Lines changed: 85414 additions & 9269 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): an action's `description` and its parameter dialog now honour the translation bundle
6+
7+
`translateAction` overlaid only `label`, `confirmText`, `successMessage` and
8+
`resultDialog`. The keys for the rest were already there — the translation
9+
schema declares `_actions.ACTION.description` and
10+
`_actions.ACTION.params.PARAM.{label, helpText, placeholder, options}`, and the
11+
translation linter validates both, reporting a parameter key the action does not
12+
declare with a did-you-mean naming the ones it does. So the keys parsed, they
13+
linted, and they resolved to nothing: a translated deployment rendered a
14+
translated action button that opened an untranslated form, because an action's
15+
`description` is the explanatory line under the dialog title and its
16+
parameters' `label` / `helpText` / `placeholder` / option labels are the rest of
17+
that dialog.
18+
19+
They are applied now, wherever the action is served — the REST metadata read,
20+
OpenAPI, MCP — and through `globalActions` for an action that belongs to no
21+
object, the same object-scoped-first order every other action key already used.
22+
23+
Parameters are matched by `name`, falling back to `field` for a field-backed
24+
parameter that names no key of its own: the same rule the linter collects
25+
parameters by, so a key the linter accepts is a key the resolver finds. Option
26+
labels are matched on the stored option `value`, since the authored side is an
27+
array of options while the translation side is a `value` to label map.
28+
29+
Nothing changes for a bundle that carries none of these keys: the authored text
30+
is kept, the parameter array keeps its identity, and a bundle key naming a
31+
parameter the action does not declare is ignored rather than invented into the
32+
dialog. No schema, no validator and no accepted shape moves — every key applied
33+
here was already declared and already validated.
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: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
---
2+
"@objectstack/metadata": patch
3+
"@objectstack/core": minor
4+
"@objectstack/objectql": patch
5+
"@objectstack/runtime": patch
6+
---
7+
8+
fix(metadata): register a `packages[]` artifact per package at the metadata door so every object has one owner across every door (#14599)
9+
10+
A release artifact carrying `packages[]` (ADR-0130 D4) was read at the metadata
11+
door as if it carried one package: `MetadataPlugin._parseAndRegisterArtifact`
12+
iterated the **flattened top level** and stamped every item with the artifact's
13+
own `manifest.id`. For an artifact composed with `composeStacks(…, { manifest:
14+
'preserve' })` that id is one arbitrary member's — `selectManifest`'s `'last'`
15+
pick — so a two-package artifact registered the **module's** object under the
16+
**App** package's identity, while the ObjectQL load path, reading the same
17+
artifact's `packages[]`, owned it under the module's.
18+
19+
The platform then held two answers to "who owns this object", and which one a
20+
consumer saw depended on the door it went through. Measured on a real boot of
21+
`examples/app-multi-package`:
22+
23+
- `GET /api/v1/meta/object` served `crm_order` **twice** — the list merge keys
24+
slots by `${packageId}${name}`, so the two differently-attributed copies
25+
landed in two slots;
26+
- `GET /api/v1/meta/object?package=<the App package>` returned the **module's**
27+
object, because the App-stamped copy was re-ingested into the registry as that
28+
package's contribution;
29+
- the layers door named the App package while the item door and
30+
`GET /api/v1/packages` named the module;
31+
- Studio's Data pillar for the App package listed the module's object — ADR-0130
32+
Consequences §1.3a ("Studio's scope is the package") did not hold.
33+
34+
**The door now reads both shapes, and attributes every item to the body it was
35+
found in.** `packages` present → each assembled package body's collections are
36+
registered stamped with **that body's** id; `packages` absent → the single
37+
`manifest` branch runs exactly as before (D7). The owner is read off the body an
38+
item was found in — never reverse-derived by matching a top-level item's name
39+
against a name-to-package index, which would be the second metadata-identity
40+
resolution path #14512's triage rejected by name.
41+
42+
**Ordering and the entry gate are reused, not re-derived (D5).** The door calls
43+
the same `resolveArtifactPackageOrder` the ObjectQL load path calls, so the two
44+
readers of one `packages[]` cannot disagree about the registration order **or**
45+
about which artifacts are loadable at all.
46+
47+
⚠️ **`resolveArtifactPackageOrder` / `artifactPackageId` moved to
48+
`@objectstack/core`** — hence the `minor` there. They were in
49+
`@objectstack/objectql`, which **depends on** `@objectstack/metadata`, so the
50+
metadata door could not import them from where they lived; `@objectstack/core`
51+
already owns `resolvePluginOrder` and is already a dependency of both readers,
52+
so hosting them there adds **no edge** to the package graph. `@objectstack/objectql`
53+
re-exports both under their existing names — its published surface is unchanged,
54+
which is why it is graded `patch`. `@objectstack/runtime` is `patch` for the
55+
dispatcher error vocabulary's `file:` anchors, repointed at the new path.
56+
57+
**Single-package artifacts are byte-for-byte unaffected (D7)**, measured rather
58+
than asserted: the whole `manager.register` sequence for a single-`manifest`
59+
artifact — every call, in order, with the id and version each item was stamped
60+
with — is pinned as a literal in
61+
`packages/metadata/src/plugin-artifact-packages-attribution.test.ts` and was
62+
recorded identically on both legs of the ablation. A real boot of
63+
`examples/app-todo` answers every door identically before and after.
64+
65+
**Nothing a booted instance can see today disappears.** Every live
66+
`ARTIFACT_FIELD_TO_TYPE` key is a member of `AssembledPackageBodySchema`
67+
(measured, not assumed), so iterating bodies loses no collection; and because
68+
`packages` composes by `concat`, an artifact whose top level carries a
69+
definition no package body repeats keeps it — registered once, attributed to the
70+
artifact's own identity, and logged, because it means the artifact's two halves
71+
disagree about what it ships.
72+
73+
⛔ The **producer** half is untouched: `composeStacks` and `os build` keep
74+
emitting the flattened top level alongside `packages[]`. Whether they should is
75+
#14512's decision, not this door's.
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.

0 commit comments

Comments
 (0)