Skip to content

Commit aefab7c

Browse files
fix(rest)!: the /meta item and list reads serve a view stored with no label without an invented label (#24013)
Fixes #23967 Clause-②: no (narrowing) ## What changes `GET /api/v1/meta/view/NAME` and `GET /api/v1/meta/view` served a view stored with no `label` as if it had one: `label` equal to its qualified name (`OBJECT.KEY`). This happened on the standalone server and on the runtime dispatcher. The spec declares a view's `label` optional with no default (`packages/spec/src/ui/view.zod.ts`, `I18nLabelSchema.optional()`). The value came from the read-side translator: `translateView` answers `view.label ?? view.name` when the catalog carries no string for the view. The view-container read already dropped that fallback (#22614); the item and list reads did not. The rule is now one module-private helper in `packages/rest/src/meta-item-read-gate.ts`, `withoutInventedViewLabel(input, translated)`. All three `view` sites call it: - `translateMetaDocument`'s single-view arm: the item read. `translateMetaEnvelope` wraps it, and `RestServer.translateMetaItem` delegates to it. - `translateMetaList`'s per-element map: the list read. `RestServer.translateMetaItems` delegates to it, and the runtime dispatcher's `/meta` domain imports it. - `projectViewTranslation`: container slots and the container's own strings. Its inline check is replaced by the helper, with the same semantics. The reverse run below shows the container answer is unchanged. The rule: a `label` equal to the view's `name`, where the stored view carried none, is not served. An authored label is served as authored, including one equal to the name. A catalog translation is still served, for a view with a label and for one without. A catalog string equal to the qualified name cannot be told apart from the fallback, so it is dropped with it, as it already was on the container path. No export is added. `packages/spec`, `rest-server.ts` and `runtime/src/domains/meta.ts` are not touched. The console half is a separate card that the seat files on objectstack-ai/objectui when this lands: `viewTabLabel` (`view.label || view.name || view.id`) needs its own human fallback. It is not addressed here, and the console tab keeps showing a machine name for such a view until then. ## Evidence ### Live boot, BASE `adce19297` vs HEAD (standalone `os dev`) From a scratch directory outside the repo: `examples/app-todo` compiled with `os compile -o SCRATCH/objectstack.json`, booted with `os dev --artifact ./objectstack.json --fresh -p PORT` and placeholder admin credentials. Recipe: `POST /packages` (201), `PUT /meta/object/recheck_gate?package=com.qa.recheck` with label, plural label and `sharingModel` (200), `PUT /meta/view/recheck_gate.default` with no `label` (200), `PUT /meta/view/recheck_gate.named` with `label: "All gates"` (200). | read (no Accept-Language, `en`, and `zh-CN`: the same in all three) | BASE | HEAD | | --- | --- | --- | | `GET /meta/view/recheck_gate.default` | 200, `label: "recheck_gate.default"` | 200, no `label` key | | `GET /meta/view`, element `recheck_gate.default` | 200, `label: "recheck_gate.default"` | 200, no `label` key | | `GET /meta/view`, elements whose `label` equals `name` | `[recheck_gate.default]` | `[]` | | `GET /meta/view/recheck_gate.named` and its list element | 200, `"All gates"` | 200, `"All gates"` | | `GET /meta/view/recheck_gate.default/layers`, overlay and effective | no `label` | no `label` | ### Cloud host shape, BASE vs HEAD `new HttpDispatcher(kernel)`, which is what `createHonoApp` builds, loaded from the built `@objectstack/runtime` dist. That dist imports the built `@objectstack/rest`. The protocol double serves three stored views: label-less `default`, authored `named`, and label-less `tagged`, which the `zh-CN` catalog translates. | case | BASE | HEAD | | --- | --- | --- | | default locale resolves, no Accept-Language: `default` / `tagged`, item and list | `label` = own name | no `label` key | | `zh-CN`: `default`, item and list | `label: "recheck_gate.default"` | no `label` key | | `zh-CN`: `tagged` / `named` | `已标记` / `全部闸门` | `已标记` / `全部闸门` | | no default locale, or no i18n service, no Accept-Language | no `label` key (nothing is translated) | no `label` key | The last row is the PM's mechanism assumption 2: with no resolvable locale, no other layer adds the label. ### Pins: `packages/rest/src/meta-view-label-not-invented.test.ts`, 39 rows - §1 runs through the standalone routes `GET /api/v1/meta/:type/:name` and `GET /api/v1/meta/:type`, over a real `SchemaRegistry` and the real `ObjectStackProtocolImplementation`, with the views seeded as stored rows. It has one row per locale and view: label-less (no key), authored, catalog-translated label-less, and authored equal to name. It also covers the card's read with no Accept-Language and no catalog. - §2 runs through `translateMetaEnvelope` and `translateMetaList`, the functions the dispatcher's `/meta` domain imports, with the same rows, a bare array and the `{ type, items }` envelope. It also covers no catalog, no mutation, and no locale (the same document is returned). - §3 is a CONTROL: the container read answers what it answered before. ### Reverse verification and ablation (one-shot, nothing left in the tree) The subject is reached by relative import (`./meta-item-read-gate.js`, and `./rest-server.js` imports it relatively), so it resolves to `src/`; no rebuild sits between legs. Each leg below was proven on disk. The reverse leg's blob equals the BASE blob. Each ablation went through `scripts/ablation-replace.mjs`: anchor 1 to 0, blob changed. Each restore was proven: blob equals the HEAD blob and `git diff HEAD` is empty. | leg | result | | --- | --- | | CONTROL, HEAD unmutated | 39 / 39 green | | REVERSE, the whole file at BASE | 15 red, exactly the label-less rows on both reads. All authored and translated rows and both §3 container rows stay green, so the container answer is unchanged. | | item site ablated (`return translated`) | 9 red, the item-side label-less rows only | | list site ablated (`return out`) | 9 red, the list-side label-less rows only | | container site ablated (helper bypassed in `projectViewTranslation`) | the 2 §3 rows red | ### Suites These ran post-merge at `6ff4da0d8`. The one later commit adds only the ledger row below. - `pnpm --filter @objectstack/rest typecheck`: exit 0. `tsc -p tsconfig.test.json --listFiles` includes the new file, with 0 `error TS`. - `pnpm --filter @objectstack/rest test` (`local`): 288 files, 5941 passed, 326 skipped. - `pnpm --filter @objectstack/rest test:repo` (`repo`): 5 files, 250 passed, 11 skipped. - `@objectstack/runtime`, the 25 `src/domains/meta-*.test.ts` files of the `local` project: 513 passed. The `repo` project's `meta-list-projection-parity.test.ts`: 739 passed. Runtime aliases `@objectstack/rest` to its source. - `pnpm exec eslint --no-inline-config` over the two changed TS files: exit 0. ### Gates, at the final HEAD `203a8192f` - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derives 72 commands from merge base `d132140eb`. - All 72 were run on `203a8192f`, each with its exit code captured before any pipe, and all 72 exit 0. - `--ran` reconciliation over that record reads: 72 derived, 72 run, 0 NOT-MEASURED, 0 UNRUN (a derived zero: all 72 recorded an exit code). - Included: `check:adr-0087-registration` (the changeset holds `not-required (no-migration-prescription)`), `check:changeset-no-major`, `check:empty-changeset`, `check:engine-double-contract` (1017 pinned), `check:test-source-alias`, `check:cross-package-test-inputs`, `check:nul-bytes` and `check:dual-build-cjs-loads` (110 require entry points across 68 packages load). - The repo-wide lint and every family CI owns are read from this PR's check-runs. ## Surface note The claim's file surface was the source file, one test file and one changeset. A fourth file is needed: `scripts/engine-double-contract.pinned.json` gains one row (`packages/rest/src/meta-view-label-not-invented.test.ts`, `findOne`, pinned 1). `check:engine-double-contract` requires it for the new pin's fake engine and prescribes `--write`. The regeneration printed 0 rows lost; no other row moves. ## Acceptance notes - **Other translatable types (PM assumption 5).** The `object`, `app`, `dashboard`, `dataset`, `page` and `picklist` translators serve `label` only from the catalog or the document; none falls back to `name`. `action` does: `resolveActionLabel` answers `action.name` and `translateAction` always writes `label`. But `ActionSchema` declares `label` required (`packages/spec/src/ui/action.zod.ts:959`), so a schema-valid stored action always carries one. No reach was measured, so this is not filed. - **The fallback's source (PM assumption 6).** `resolveViewLabel`'s `view.label ?? view.name` (`packages/spec/src/system/i18n-resolver.ts:481`) is unchanged. - In this repo, `translateView` is called by this module's three sites and by the public-form route in `rest-server.ts`. That route takes only the translated `config` and keeps its own label reading. - `packages/cli/src/utils/i18n-extract.ts` names both functions in docblocks only, with no import. - objectui main (`63d00da`) calls neither. - After this PR, no served door carries the fallback. The exported function's answer is `domain:spec` ground; it is noted and not filed. - **Consumers (PM assumption 4).** - The published response types are `GetMetaItemResponseSchema.item: z.unknown()` and `GetMetaItemsResponseSchema.items: z.array(z.unknown())`. - No `packages/client` type requires a served view's `label`, and no test asserts that a served view's `label` equals its name. - The CLI's `serve-view-container-read` integration test asserts authored labels. - The dogfood `view-container-cross-package-default` test compares the item and list reads, which now take the same rule. - The card's object recipe now needs `sharingModel`. Without it the PUT answers 422 `security-owd-unset`, an authoring rule that postdates the card's run. - Booting a compiled artifact from a directory with no `node_modules` logged `runtime module load FAILED ... Cannot find package '@objectstack/spec'`, and the todo app's hook was refused at boot. This was seen only in this scratch setup, and it does not touch `/meta`. Whether an artifact is meant to boot without its dependencies is not established here; noted, not filed. --- _Generated by [Claude Code](https://claude.ai/code/session_018t6a5rvqPRvEDMCVVW9kYT)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent bb14934 commit aefab7c

4 files changed

Lines changed: 417 additions & 11 deletions

File tree

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
---
2+
'@objectstack/rest': minor
3+
---
4+
5+
fix(rest)!: the `/meta` item and list reads serve a view stored with no `label` without the `label` the translator invents from its name
6+
7+
Clause-②: no (narrowing)
8+
9+
<!-- adr-0087: not-required (no-migration-prescription) No metadata moves: no spec key, authorable spelling, object definition or stored shape is added, removed, renamed or re-shaped, and no exported declaration is removed or narrowed, so there is nothing for `objectstack migrate meta` to rewrite. What changes is the document two `/meta` reads serve for a view whose stored row carries no `label`: it no longer carries one the serving layer made up. The stored row was never touched and is not touched now. The other categories are closed on facts: the package publishes (not unpublished); no ADR-0087 id is named or touched (not registered or already-registered); and nothing exported is removed or narrowed (not runtime-interface-only, type-surface-only or runtime-export-retired). -->
10+
11+
**BREAKING** (a `/meta` read stops serving a value nobody authored), shipped as `minor` under the launch-window convention for breaking changes.
12+
13+
A view's `label` is optional in the spec, with no default. When the translation catalog carried no string for a view, the read-side translator answered the view's own name, so a view stored without a `label` was served one: `GET /api/v1/meta/view/NAME` and `GET /api/v1/meta/view` returned `label: "OBJECT.KEY"`, on the standalone server and on the runtime dispatcher alike. The console drew that machine name as the view tab. The view-container read already dropped this value; the item read and the list read now drop it too.
14+
15+
What a client reads now:
16+
17+
- A view stored with no `label` is served with no `label` key, in every locale, with or without a translation catalog.
18+
- A view with an authored `label` is served that label, unchanged, including a label that happens to equal the view's name.
19+
- A catalog translation (`objects.OBJECT._views.KEY.label`) is still served, for a view with a `label` and for one without.
20+
- A read that resolves no locale is served the stored document as before, and the layered read (`?layers=true`) never carried the invented value.
21+
22+
Who reads differently: a client that displays a served view's `label` now finds the key absent for a view stored without one, where it used to find the qualified name; it shows its own wording for such a view (the object's plural label, for example). The console's view tab falls back to the view's name on its own, so it keeps showing a machine name for such a view until its own fallback changes.

‎packages/rest/src/meta-item-read-gate.ts‎

Lines changed: 55 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2794,6 +2794,10 @@ export interface MetaListTranslationSources {
27942794
* applies built-in fallbacks (the injected system-field labels on custom
27952795
* objects). No locale is: nothing is translated then.
27962796
*
2797+
* [#23967] A `view` element is served without the `label` the translator
2798+
* falls back to when the view stored none ({@link withoutInventedViewLabel}),
2799+
* the rule the item read and the container read apply.
2800+
*
27972801
* `RestServer.translateMetaItems` delegates here, and the list chain
27982802
* ({@link createMetaListAnswer}) runs it as its last step on both transports.
27992803
*/
@@ -2820,10 +2824,13 @@ export async function translateMetaList(
28202824
const p = PACKAGED_BASE_ACCESSORS.has(metaType) ? await sources.resolveProtocol() : undefined;
28212825
// `getMetaItems` elements are metadata documents (the list envelope is the
28222826
// OUTER `{ type, items }`), so every element translates directly (#5563).
2823-
const translated = arr.map((item) => translateMetadataDocument(metaType, item, bundle, {
2824-
...metaTranslateOptions(i18n, locale),
2825-
packagedBase: packagedObjectBaseOf(p, metaType, item?.name),
2826-
}));
2827+
const translated = arr.map((item) => {
2828+
const out = translateMetadataDocument(metaType, item, bundle, {
2829+
...metaTranslateOptions(i18n, locale),
2830+
packagedBase: packagedObjectBaseOf(p, metaType, item?.name),
2831+
});
2832+
return metaType === 'view' && item && typeof item === 'object' ? withoutInventedViewLabel(item, out) : out;
2833+
});
28272834
return Array.isArray(raw) ? translated : { ...raw, items: translated };
28282835
}
28292836

@@ -2851,6 +2858,10 @@ export async function translateMetaList(
28512858
* type `view` — the registry files a `defineView` container under the object
28522859
* it binds and serves it on that key's by-name read — and the spec translator
28532860
* the table dispatches `view` to reads a single view's `name`.
2861+
*
2862+
* [#23967] A single view is served without the `label` that translator falls
2863+
* back to when the view stored none ({@link withoutInventedViewLabel}) — the
2864+
* rule the container read and the list read apply.
28542865
*/
28552866
export async function translateMetaDocument(
28562867
sources: MetaListTranslationSources,
@@ -2878,10 +2889,11 @@ export async function translateMetaDocument(
28782889
const packagedBase = PACKAGED_BASE_ACCESSORS.has(metaType)
28792890
? packagedObjectBaseOf(await sources.resolveProtocol(), metaType, (document as any)?.name)
28802891
: undefined;
2881-
return translateMetadataDocument(metaType, document as any, bundle, {
2892+
const translated = translateMetadataDocument(metaType, document as any, bundle, {
28822893
...metaTranslateOptions(i18n, locale),
28832894
packagedBase,
28842895
});
2896+
return metaType === 'view' ? withoutInventedViewLabel(document as Record<string, unknown>, translated) : translated;
28852897
}
28862898

28872899
/**
@@ -2912,8 +2924,9 @@ export async function translateMetaDocument(
29122924
*
29132925
* A string the catalog does not carry is never invented: the translator falls
29142926
* back to a view's `name` for a missing label, and that fallback is not a
2915-
* translation ({@link projectViewTranslation}). With no bundle, nothing
2916-
* changes and the SAME container is returned.
2927+
* translation ({@link withoutInventedViewLabel}, through
2928+
* {@link projectViewTranslation}). With no bundle, nothing changes and the SAME
2929+
* container is returned.
29172930
*
29182931
* Known limit: a container saved at runtime on ANOTHER package's object is
29192932
* expanded by the metadata protocol under its own name (`<object>.<name>.<key>`),
@@ -3004,8 +3017,8 @@ async function translateMetaViewContainer(
30043017
* lands on `target`, and so does every key it replaced in the view's `config`
30053018
* (`bulkActionDefs`): an expanded view nests its whole list or form body under
30063019
* `config`, and the slot IS that body. The expansion's own identity keys are
3007-
* never copied. A `label` that equals `input.name` where `input` had none is
3008-
* the translator's fallback, not a translation, and is dropped.
3020+
* never copied. The translator's `label` fallback is not a translation and
3021+
* never lands ({@link withoutInventedViewLabel}).
30093022
*/
30103023
function projectViewTranslation(
30113024
target: Record<string, unknown>,
@@ -3018,9 +3031,8 @@ function projectViewTranslation(
30183031
if (out === target) out = { ...target };
30193032
out[key] = value;
30203033
};
3021-
for (const [key, value] of Object.entries(translated)) {
3034+
for (const [key, value] of Object.entries(withoutInventedViewLabel(input, translated))) {
30223035
if (key === 'config' || VIEW_EXPANSION_IDENTITY_KEYS.has(key) || value === input[key]) continue;
3023-
if (key === 'label' && input.label == null && value === input.name) continue;
30243036
set(key, value);
30253037
}
30263038
const config = translated.config;
@@ -3033,6 +3045,38 @@ function projectViewTranslation(
30333045
return out;
30343046
}
30353047

3048+
/**
3049+
* [#22614 · #23967] `translated` — what the spec's `translateView` answered for
3050+
* the view `input` — without the `label` that translator invents. It answers
3051+
* `input.label ?? input.name` when the catalog carries no string for the view,
3052+
* so a view stored with no `label` comes back carrying its own name, and the
3053+
* spec declares a view's `label` optional with no default: that value was
3054+
* authored by nobody and translated from nothing, and a reader could tell it
3055+
* from a real label only by comparing it to `name`.
3056+
*
3057+
* The rule: a `label` equal to `input.name` where `input` carried none (absent
3058+
* or nullish) is the fallback, and `input`'s own value takes its place — no
3059+
* `label` key when `input` had none. An authored `label`, even one equal to the
3060+
* view's name, and a catalog string that differs from the name are served
3061+
* unchanged; a catalog string equal to the name cannot be told from the
3062+
* fallback and goes with it.
3063+
*
3064+
* ONE rule for every `view` read this module translates: the item read
3065+
* ({@link translateMetaDocument}), the list read ({@link translateMetaList})
3066+
* and each part of a container ({@link projectViewTranslation}). Returns
3067+
* `translated` itself when nothing is dropped.
3068+
*/
3069+
function withoutInventedViewLabel(
3070+
input: Record<string, unknown>,
3071+
translated: Record<string, unknown>,
3072+
): Record<string, unknown> {
3073+
if (input.label != null || translated.label !== input.name) return translated;
3074+
const served: Record<string, unknown> = { ...translated };
3075+
if ('label' in input) served.label = input.label;
3076+
else delete served.label;
3077+
return served;
3078+
}
3079+
30363080
/** The keys `expandViewContainer` stamps on an item — its identity, never the view's own content. */
30373081
const VIEW_EXPANSION_IDENTITY_KEYS: ReadonlySet<string> = new Set([
30383082
'name', 'object', 'viewKind', 'order', 'scope', 'isDefault', '_diagnostics',

0 commit comments

Comments
 (0)