Skip to content

Commit 6fcd4ed

Browse files
committed
fix(rest): the by-name read of a view container translates it as a container
The /meta item read handed a defineView container to the single-view translator, which keys the catalog on the view's name. An artifact-shipped container has none, so the artifact boot answered 500; the config boot's registrar stamps one, so that boot served a fabricated label and left every view inside untranslated. The read now classifies the served view document once: a container's views are translated under the names the composer gives them, with their own packaged bases, and land back in their slots. Claude-Session: https://claude.ai/code/session_01HGa2XMKBRUqPrZFxQavBwH Co-authored-by: Claude <noreply@anthropic.com>
1 parent 3d0eeef commit 6fcd4ed

3 files changed

Lines changed: 180 additions & 4 deletions

File tree

‎packages/rest/package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
},
3030
"dependencies": {
3131
"@objectstack/core": "workspace:*",
32+
"@objectstack/metadata": "workspace:*",
3233
"@objectstack/metadata-core": "workspace:*",
3334
"@objectstack/observability": "workspace:*",
3435
"@objectstack/platform-objects": "workspace:*",
@@ -40,7 +41,6 @@
4041
},
4142
"devDependencies": {
4243
"@objectstack/driver-sql": "workspace:*",
43-
"@objectstack/metadata": "workspace:*",
4444
"@objectstack/metadata-protocol": "workspace:*",
4545
"@objectstack/objectql": "workspace:*",
4646
"@objectstack/plugin-hono-server": "workspace:*",

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

Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,10 @@ import {
7070
type ObjectSchemaMaskPosture,
7171
} from '@objectstack/metadata-core';
7272
import { ANONYMOUS_DENY_CODE, ANONYMOUS_DENY_MESSAGE, ANONYMOUS_DENY_STATUS } from '@objectstack/core';
73+
// [#22614] The registry's one spelling of a container's binding, through its
74+
// import-free leaf entry (that module's header measures why the leaf exists).
75+
import { deriveViewContainerObject } from '@objectstack/metadata/view-container';
76+
import type { ExpandedViewItem } from '@objectstack/spec/ui';
7377
import { isEndpointMatchAuthority, selectServedEndpoints } from './served-endpoints.js';
7478
import { logError, logWarn } from './log.js';
7579

@@ -2233,6 +2237,13 @@ export async function translateMetaList(
22332237
* Moved here, unchanged, from `RestServer.translateMetaItem` (which now
22342238
* delegates), so the runtime dispatcher's item read translates with the same
22352239
* function instead of serving the authored labels.
2240+
*
2241+
* [#22614] A `view` document is classified ONCE, here, before any translator
2242+
* reads it: a view CONTAINER goes to {@link translateMetaViewContainer}, a
2243+
* single view to the spec's view translator. Both shapes are served under the
2244+
* type `view` — the registry files a `defineView` container under the object
2245+
* it binds and serves it on that key's by-name read — and the spec translator
2246+
* the table dispatches `view` to reads a single view's `name`.
22362247
*/
22372248
export async function translateMetaDocument(
22382249
sources: MetaListTranslationSources,
@@ -2245,6 +2256,17 @@ export async function translateMetaDocument(
22452256
const bundle = translationBundleOf(i18n);
22462257
const locale = sources.requestLocale(i18n);
22472258
if (!locale) return document;
2259+
if (metaType === 'view') {
2260+
const { isAggregatedViewContainer } = await import('@objectstack/spec/ui');
2261+
if (isAggregatedViewContainer(document)) {
2262+
return translateMetaViewContainer(
2263+
sources,
2264+
document as Record<string, unknown>,
2265+
bundle,
2266+
metaTranslateOptions(i18n, locale),
2267+
);
2268+
}
2269+
}
22482270
const { translateMetadataDocument } = await import('@objectstack/spec/system');
22492271
const packagedBase = PACKAGED_BASE_ACCESSORS.has(metaType)
22502272
? packagedObjectBaseOf(await sources.resolveProtocol(), metaType, (document as any)?.name)
@@ -2255,6 +2277,160 @@ export async function translateMetaDocument(
22552277
});
22562278
}
22572279

2280+
/**
2281+
* [#22614] Translate a served view CONTAINER as a container — the by-name read
2282+
* of a `defineView` document, which the registry files under the object it
2283+
* binds ({@link deriveViewContainerObject}).
2284+
*
2285+
* The spec's `translateView` is the translator for ONE view, and it keys the
2286+
* catalog on that view's `name`. A container has no `name` of its own when a
2287+
* compiled artifact ships it (only the config boot's registrar stamps one), so
2288+
* handing the container to it threw on the artifact boot, and on the config
2289+
* boot it served the container as one view keyed by the object: a fabricated
2290+
* `label` equal to the object name, and every view inside left untranslated.
2291+
* ⛔ Guarding the `name` read instead would leave the container untranslated
2292+
* in silence.
2293+
*
2294+
* So a container is translated in two parts, each through `translateView`
2295+
* under its own key rule:
2296+
*
2297+
* - **each view it expands to**, under the name the composer gives it
2298+
* (`expandViewContainer`, the one producer of a view's runtime identity, the
2299+
* same expansion the registrars run), with that view's packaged base looked
2300+
* up by that name — exactly what the by-name read of the expanded view
2301+
* (`<object>.<key>`) is translated with, so the two reads cannot disagree.
2302+
* The result lands back in the slot the view came from;
2303+
* - **the container's own `label` and `description`**, keyed by the object it
2304+
* binds, with the packaged container as the base.
2305+
*
2306+
* A string the catalog does not carry is never invented: the translator falls
2307+
* back to a view's `name` for a missing label, and that fallback is not a
2308+
* translation ({@link projectViewTranslation}). With no bundle, nothing
2309+
* changes and the SAME container is returned.
2310+
*
2311+
* Known limit: a container saved at runtime on ANOTHER package's object is
2312+
* expanded by the metadata protocol under its own name (`<object>.<name>.<key>`),
2313+
* which this function does not reproduce; its views are translated under the
2314+
* composer's names.
2315+
*/
2316+
async function translateMetaViewContainer(
2317+
sources: MetaListTranslationSources,
2318+
container: Record<string, unknown>,
2319+
bundle: unknown,
2320+
options: ReturnType<typeof metaTranslateOptions>,
2321+
): Promise<Record<string, unknown>> {
2322+
const object = deriveViewContainerObject(container);
2323+
// The registrars file nothing they cannot bind, so no read serves such a container.
2324+
if (object === undefined) return container;
2325+
const [{ translateView }, { expandViewContainer }] = await Promise.all([
2326+
import('@objectstack/spec/system'),
2327+
import('@objectstack/spec/ui'),
2328+
]);
2329+
const protocol = await sources.resolveProtocol();
2330+
const translate = (view: Record<string, unknown>, baseName: unknown): Record<string, unknown> =>
2331+
translateView(view as any, bundle as any, {
2332+
...options,
2333+
packagedBase: packagedObjectBaseOf(protocol, 'view', baseName),
2334+
}) as unknown as Record<string, unknown>;
2335+
2336+
// The container's own strings. The key the registry files it under is its
2337+
// `name` when it carries one (a source registrar refuses a `name` that is
2338+
// not the object, a save stamps the save name), else the object.
2339+
const ownName = typeof container.name === 'string' && container.name !== '' ? container.name : object;
2340+
const ownInput = { ...container, name: object, object };
2341+
let next = projectViewTranslation(container, ownInput, translate(ownInput, ownName));
2342+
2343+
// Each view, by the item the composer expands its slot into.
2344+
const items = expandViewContainer(object, container) as ExpandedViewItem[];
2345+
const lists = items.filter((item) => item.viewKind === 'list');
2346+
const forms = items.filter((item) => item.viewKind === 'form');
2347+
const slotTranslation = (slot: unknown, item: ExpandedViewItem | undefined): unknown => {
2348+
if (item === undefined || !slot || typeof slot !== 'object' || Array.isArray(slot)) return slot;
2349+
const input = item as unknown as Record<string, unknown>;
2350+
return projectViewTranslation(slot as Record<string, unknown>, input, translate(input, item.name));
2351+
};
2352+
const put = (key: string, value: unknown): void => {
2353+
if (value === next[key]) return;
2354+
if (next === container) next = { ...container };
2355+
next[key] = value;
2356+
};
2357+
const slotRecord = (key: string, itemFor: (slotKey: string, slot: unknown) => ExpandedViewItem | undefined): void => {
2358+
const record = next[key];
2359+
if (!record || typeof record !== 'object' || Array.isArray(record)) return;
2360+
let out = record as Record<string, unknown>;
2361+
for (const [slotKey, slot] of Object.entries(record)) {
2362+
const translated = slotTranslation(slot, itemFor(slotKey, slot));
2363+
if (translated === slot) continue;
2364+
if (out === record) out = { ...(record as Record<string, unknown>) };
2365+
out[slotKey] = translated;
2366+
}
2367+
put(key, out);
2368+
};
2369+
// The composer's order, from its docblock: each family's NAMED views first,
2370+
// in entry order, then the default. A `listViews` entry is the first claim
2371+
// on its `<object>.<key>`, so it is never renamed and is found by name. The
2372+
// default `list` and the default `form` are the items the composer flags
2373+
// `isDefault` (a `list` restating a named list collapses into that item; a
2374+
// named list is flagged only when there is no `list`). A `formViews` entry
2375+
// can be renamed by a list that took its name, so it is found by position
2376+
// among the named forms — entries that are not objects expand to nothing.
2377+
slotRecord('listViews', (slotKey) => lists.find((item) => item.name === `${object}.${slotKey}`));
2378+
if (next.list && typeof next.list === 'object') {
2379+
put('list', slotTranslation(next.list, lists.find((item) => item.isDefault)));
2380+
}
2381+
const namedForms = forms.filter((item) => !item.isDefault);
2382+
let formPosition = 0;
2383+
slotRecord('formViews', (_slotKey, slot) =>
2384+
slot && typeof slot === 'object' ? namedForms[formPosition++] : undefined);
2385+
if (next.form && typeof next.form === 'object') {
2386+
put('form', slotTranslation(next.form, forms.find((item) => item.isDefault)));
2387+
}
2388+
return next;
2389+
}
2390+
2391+
/**
2392+
* [#22614] Land what `translateView` changed on `input` onto `target` — the
2393+
* container itself, or the slot `input` was expanded from — copy on write,
2394+
* `target` itself when nothing changed.
2395+
*
2396+
* Every top-level string the translator overlays (`label`, `description`)
2397+
* lands on `target`, and so does every key it replaced in the view's `config`
2398+
* (`bulkActionDefs`): an expanded view nests its whole list or form body under
2399+
* `config`, and the slot IS that body. The expansion's own identity keys are
2400+
* never copied. A `label` that equals `input.name` where `input` had none is
2401+
* the translator's fallback, not a translation, and is dropped.
2402+
*/
2403+
function projectViewTranslation(
2404+
target: Record<string, unknown>,
2405+
input: Record<string, unknown>,
2406+
translated: Record<string, unknown>,
2407+
): Record<string, unknown> {
2408+
let out = target;
2409+
const set = (key: string, value: unknown): void => {
2410+
if (value === out[key]) return;
2411+
if (out === target) out = { ...target };
2412+
out[key] = value;
2413+
};
2414+
for (const [key, value] of Object.entries(translated)) {
2415+
if (key === 'config' || VIEW_EXPANSION_IDENTITY_KEYS.has(key) || value === input[key]) continue;
2416+
if (key === 'label' && input.label == null && value === input.name) continue;
2417+
set(key, value);
2418+
}
2419+
const config = translated.config;
2420+
const inputConfig = input.config;
2421+
if (config !== inputConfig && config && typeof config === 'object' && inputConfig && typeof inputConfig === 'object') {
2422+
for (const [key, value] of Object.entries(config)) {
2423+
if (value !== (inputConfig as Record<string, unknown>)[key]) set(key, value);
2424+
}
2425+
}
2426+
return out;
2427+
}
2428+
2429+
/** The keys `expandViewContainer` stamps on an item — its identity, never the view's own content. */
2430+
const VIEW_EXPANSION_IDENTITY_KEYS: ReadonlySet<string> = new Set([
2431+
'name', 'object', 'viewKind', 'order', 'scope', 'isDefault', '_diagnostics',
2432+
]);
2433+
22582434
/**
22592435
* [#5563 · #10235 · #20408] The one place a single-item read rebuilds its
22602436
* response body around the document it serves: `envelope` supplies the

‎pnpm-lock.yaml‎

Lines changed: 3 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)