Skip to content

Commit 4b7c630

Browse files
fix(rest): the by-name read of a view container answers 200 on an artifact boot and translates the views inside it (#22614) (#22687)
Fixes #22614 Clause-②: no A `500` is a fault, not a refusal. No accepted set, export or error code changes. The by-name read of a view container now answers on every boot, translated. ## The defect, re-measured on this branch's base (`3d0eeefa4`) One single-package project: an object `acme_note`, a `defineView({ object: 'acme_note', list, listViews: { mine } })` container, and a `zh-CN` catalog for both views. The read is `GET /api/v1/meta/view/acme_note`, authenticated as the seeded admin. | boot | before | after | | --- | --- | --- | | artifact (`os build`, then `os serve` of the artifact) | `500 INTERNAL_ERROR`; log: `Cannot read properties of undefined (reading 'startsWith')` | `200`; `list.label` is `全部笔记`, `listViews.mine.label` is `我的笔记` | | config (`os serve objectstack.config.ts`) | `200`, but `label: "acme_note"` is served (no author wrote it), and both views stay `All Notes` / `My Notes` for a `zh-CN` reader | `200`; the same container as the artifact boot serves | | control: `GET /meta/view/acme_note.default`, `/acme_note.mine` | `200`, translated | unchanged, on both boots | **Why the two boots served different documents.** Both registrars file the container under its object. The config boot's ObjectQL boot loop stamps the derived key onto the item (`packages/objectql/src/engine.ts:7575`, `toRegister = item.name === itemName ? item : { ...item, name: itemName }`). The artifact loader registers the container as shipped, with no `name` (`packages/metadata/src/plugin.ts:1198`). The read handed either one to the spec's single-view translator (`translateMetadataDocument('view', …)` → `translateView` → `viewTranslationKey`), which keys the catalog on `view.name`. With no name it threw. With the stamped name it served the container as ONE view keyed by the object: the name became the label fallback, and no view inside was translated. The config boot's `200` was the same fault without the crash. ## The fix: `packages/rest/src/meta-item-read-gate.ts` only `translateMetaDocument` (both transports call it: `RestServer` and the runtime dispatcher's `/meta` item read) classifies a `view` document once, with the spec's own `isAggregatedViewContainer`. A single view goes to the spec translator, unchanged. A container goes to `translateMetaViewContainer`, which translates it as a container, through `translateView` under each part's own key rule: - **Each view inside it** (`list`, `form`, each `listViews` / `formViews` entry) is translated as the item the composer expands it into. That is `expandViewContainer`, the same expansion both registrars run, so the view keeps its own `name` (`OBJECT.KEY`). Its packaged base is looked up by that qualified name. This is exactly what the by-name read of the expanded view is translated with, so the container read and the expanded read cannot disagree. The pin asserts that parity. The result lands back in the slot the view came from. The slot mapping follows the composer's documented order: a `listViews` entry is found by name (it is the first claim on its key, so it is never renamed). The default `list` / `form` is the item the composer flags `isDefault`, which covers a `list` that restates a named list and collapses into it. A `formViews` entry is found by position among the named forms, which covers one renamed by a list that took its key. - **The container's own `label` / `description`** are keyed by the object it binds (`objects.OBJECT._views.OBJECT`), with the packaged container as the base. That is the key the config boot already read for a container whose `name` is its object, and the key the i18n extractor scaffolds for such a container. - **Nothing is invented.** `translateView` falls back to a view's `name` for a missing label. That fallback is dropped (it is not a translation), which is what removes the config boot's `label: "acme_note"`. With no catalog string, the SAME document reference comes back. - ⛔ Not `view.name?.`: that would leave a container untranslated in silence (triage `6094457162`). The binding comes from `deriveViewContainerObject` (`@objectstack/metadata/view-container`, the registry's one spelling, through its import-free leaf entry). It is not a fifth copy of the `object → list.data.object → form.data.object → name` chain. `packages/spec` is untouched, and so is `packages/rest/src/rest-server.ts` (PR #22628's region): the classification lives in the gate. ### Packaging: `@objectstack/metadata` moves from `devDependencies` to `dependencies` in `@objectstack/rest` What imports it at runtime, measured on the rebuilt `packages/rest/dist/index.js` at `7c71a9e34` (top-level ESM `import … from` lines): - `@objectstack/metadata/view-container`, 2 lines. One is this PR's import in `meta-item-read-gate.ts`. The other sits in the bundled `../objectql/dist/index.mjs` section. - `@objectstack/metadata/errors`, 5 lines, and `@objectstack/metadata/view-container-name`, 2 lines. These are in the bundled `../metadata-protocol/src/protocol.ts` and `../objectql/dist/index.mjs` sections. So the published package already loaded `@objectstack/metadata` at module load without declaring it. tsup bundles the dev dependency `@objectstack/objectql`, reached through `rest-server.ts`'s optional `await import('@objectstack/objectql')`. That bundled code's own external imports of `@objectstack/metadata/*` are hoisted into rest's top-level imports. `packages/objectql/dist/index.mjs` carries the same three specifiers (`/errors` ×2, `/view-container` ×1, `/view-container-name` ×1). The manifest graph does not change (the edge existed as a dev edge), and `check:workspace-manifest-cycles` and `check:undeclared-dep-imports` both pass. ## Tests All at `7c71a9e34` (this branch merged with `origin/main` `ee3ae0360`), unless a line says otherwise. - **Function pin**, `packages/rest/src/meta-view-container-i18n.test.ts`: 10 passed. It covers both registrar shapes translating alike, CONTROL parity with the expanded by-name reads, nothing invented, the same reference with no applicable string or no locale, a collapsed default list, a renamed default list (`default_2`), a renamed named form (`edit_2`) beside the default `form`, bulk-action defs in their slot, and the per-view packaged base looked up by qualified name (an explicit override beats the catalog). - **Boot pin**, `packages/cli/test/serve-view-container-read.integration.test.ts` (integration tier: it spawns `os serve`; tier partition pin 22/22): one fixture, a real config boot plus `os build` and a real artifact boot. 6 passed. It covers each boot answering `200` with both views translated for `zh-CN` and no fabricated `label`, authored labels for `en`, the same container from both boots, and CONTROL expanded reads unchanged on both. **Before the fix** (rest `dist` built at `3d0eeefa4`): 4 failed, 2 passed. The artifact boot answered 500, the config boot served `All Notes`, and the two containers differed. The two CONTROL / `en` config cases stayed green. - **Ablation**, committed fix at `f63e6a1d1`. The classification's `if (metaType === 'view')` was mutated through `scripts/ablation-replace.mjs` (anchor 1 → 0, blob `fa91bb79` → `cf670c38`). - Function pin on mutated `src`: 10 of 10 failed. - `pnpm --filter @objectstack/rest build`, then `ablation-dist-preflight` reported the marker present in 2 built files. Boot pin: 4 failed, 2 passed. - Restore: blob back to `fa91bb79` (equal to `HEAD`), `git diff HEAD` empty. Rebuild, then the `--absent` preflight passed: marker absent from all 6 built files, tree clean. Boot pin: 6 passed. - A first attempt at the dist leg is void: its rebuild never got the verify lock (exit 99) and its preflight reported the marker absent. It was stopped and restored, and its run is not counted. - `pnpm --filter @objectstack/rest typecheck`: exit 0 (including `check:test-typecheck`, 0 debt). - `@objectstack/rest` full suite (`vitest run`, both projects), at `f63e6a1d1`: 275 files, 5180 passed, 327 skipped, exit 0. The merge brought nothing under `packages/rest`, `packages/metadata` or `packages/metadata-protocol`. The incoming `objectql/engine.ts` change is file-field hydration. So at `7c71a9e34` the pins, the typecheck and the runtime consumers below were re-run, not the full suite. - Runtime dispatcher consumers of `translateMetaDocument` (`packages/runtime/src/domains/meta-item-read-gate-parity`, `meta-item-envelope`, `meta-list-projection-parity`, `meta-read-org-scope-parity`, `meta-package-all-fold`): 5 files, 793 passed. - **Gates**: `dispatch-gates --commands` at `7c71a9e34` derived 76 families. All 76 were run, and every exit code is 0. `--ran` reconciliation: 76 derived, 76 run, 0 NOT-MEASURED, 0 UNRUN. Two families needed a second run: - `check:dual-build-cjs-loads`: the first run reported `PREREQUISITE NOT MET` (9 unbuilt dists). The rerun passed (107 require entries across 66 packages load). - `check:query-options-erasure`: its self-test passed, but the first run's 420 s cap cut the ratchet. The rerun passed in 406 s. - **Lint**, a proven narrowing (CI runs the full `pnpm lint`), at `7c71a9e34`: `eslint --no-inline-config --format json packages/rest/src packages/cli/test/serve-view-container-read.integration.test.ts` exited 0. It linted 303 files, ignored 0, with 0 errors and 0 warnings, and all three touched `.ts` files are in the linted set. - Population: `eslint.config.mjs`'s own `files` globs (`packages/**/*.{ts,tsx,mts,cts}`, `**/*.{ts,…}`). No file reported as ignored. - Invariance: the config enables no type-aware linting. All 8 `parserOptions` are `{ ecmaVersion, sourceType }`, with no `project`. Its only cross-file reads are its own two baselines, which this diff does not touch. So no untouched file's verdict can move. ## Acceptance notes - **Known limit.** A container saved at runtime on ANOTHER package's object is expanded by the metadata protocol under its own name (`OBJECT.NAME.KEY`, `expandUnderOwnName`), which the gate does not reproduce. Its views are translated under the composer's names. This is stated in the function's docblock. - **The list read is untouched.** `getMetaItems` filters containers out of every `view` list (`protocol.ts`, `isAggregatedViewContainer`), so no container reaches `translateMetaList`. Measured: `GET /meta/view?object=acme_note` answered `200` with only the expanded items on both boots, at base. - **The pin's home.** No artifact-vs-config parity file exists on `main` yet. PR #22612's `serve-config-boot-residual.integration.test.ts` is in flight, so this is a sibling file built on the same helpers. - **PR #22612.** It changes how a MULTI-package config boot registers its unowned top level, through `MetadataPlugin.registerUnclaimedTopLevel`, which registers residual containers as shipped (no `name`). It reads only the list, so its read of such a container's by-name door is not covered there. This fix covers that shape too, since the container is classified by shape and not by `name`. This fixture is single-package (no `packages[]`), and #22612's own CONTROL pins a single-package config as unchanged. I checked this by reading its diff, not by a merged run (NOT MEASURED: a boot of this pin on a merge of #22612's head). - **Observation, not filed** (carrier: none). Rest's `dist` bundles its dev dependency `@objectstack/objectql` (and metadata-protocol sections), about 107k lines. It is reached through the `try { await import('@objectstack/objectql') } catch { … }` in `rest-server.ts`, whose comment says a deployment without the data engine "must degrade rather than fail to load". With the bundle, that import never fails, and a second copy of the objectql module ships inside rest. No wrong answer was reproduced. - **Observation, not filed** (carrier: none). `i18n-extract.ts`'s top-level view walk sends a container that carries its own `name` down the single-view branch. It scaffolds `_views.NAME` and none of its views' keys, so a named container's `_views.default` / `listViews` keys are never scaffolded. Not exercised through a public door here. --- _Generated by [Claude Code](https://claude.ai/code/session_01HGa2XMKBRUqPrZFxQavBwH)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent e250d8f commit 4b7c630

6 files changed

Lines changed: 722 additions & 4 deletions

File tree

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
'@objectstack/rest': patch
3+
---
4+
5+
`GET /api/v1/meta/view/<object>`, the by-name read of a `defineView` container, answers `200` on an artifact boot, and translates the views inside the container on every boot
6+
7+
Clause-②: no
8+
9+
The registry files a `defineView({ object, list, listViews, … })` container under the object it binds, and serves it on the by-name read of that key. The read handed the container to the translator for one view, which reads the view's own `name`.
10+
11+
- **Artifact boot** (`os build`, then `os serve` of the artifact): a compiled container carries no `name`, so the read answered `500 INTERNAL_ERROR` and the log read `Cannot read properties of undefined (reading 'startsWith')`. It now answers `200`.
12+
- **Config boot** (`os serve objectstack.config.ts`): its registrar stamps `name: <object>` onto the container, so the read answered `200`. But it served a `label` equal to the object name, which no author wrote, and it left every view inside the container in the authored language. That label is no longer served.
13+
- **What is translated now, on both boots.** Each view inside the container (`list`, `form`, each `listViews` and `formViews` entry) carries the same `label`, `description` and bulk-action copy as the by-name read of the view it expands to (`<object>.<key>`). That copy is keyed by the same `objects.<object>._views.<key>` entry and judged against the same packaged base, so an organization's explicit override still beats the catalog. The container's own `label` and `description` are read from `objects.<object>._views.<object>`, and are left unchanged when the catalog has no entry there.
14+
- **What stays the same.** The by-name reads of the expanded views and the `GET /api/v1/meta/view` list are unchanged. The list still never includes a container.
15+
- **Packaging.** `@objectstack/rest` now lists `@objectstack/metadata` under `dependencies` (it was a dev dependency). The read imports `deriveViewContainerObject` from `@objectstack/metadata/view-container`, the registry's own derivation of the object a container binds.
Lines changed: 323 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,323 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* #22614 — the by-name read of a `defineView` container answers the same on an
5+
* ARTIFACT boot and on a CONFIG boot of one single-package project, with every
6+
* view inside it translated.
7+
*
8+
* ## The defect, as measured through the doors
9+
*
10+
* `GET /api/v1/meta/view/acme_note`, authenticated, where `acme_note` is the
11+
* key the registry files the project's `defineView({ object: 'acme_note', … })`
12+
* container under:
13+
*
14+
* - `os build`, then `os serve` of the artifact: `500 INTERNAL_ERROR`, the log
15+
* reading `Cannot read properties of undefined (reading 'startsWith')`. The
16+
* compiled container carries no `name`, and the read handed it to the
17+
* single-view translator, which keys the catalog on `name`;
18+
* - `os serve objectstack.config.ts`: `200`, from a container the config
19+
* boot's registrar had stamped `name: 'acme_note'` onto — served with a
20+
* fabricated `label: 'acme_note'` and both views inside it in English for a
21+
* `zh-CN` reader.
22+
*
23+
* The read now classifies the served view document once and translates a
24+
* container as a container (`translateMetaDocument`, `@objectstack/rest`;
25+
* pinned function-level by `meta-view-container-i18n.test.ts` there).
26+
*
27+
* ## What each boot is asked
28+
*
29+
* - THE CARD'S READ, in `zh-CN` and in `en`: the container, whose views carry
30+
* the catalog's labels for the reader's locale, and no label the project
31+
* never wrote;
32+
* - CONTROL: the by-name reads of the two views it expands to
33+
* (`acme_note.default`, `acme_note.mine`), translated as they always were.
34+
*
35+
* ⚠️ Not counts: each assertion reads a label the catalog carries for exactly
36+
* one view and one locale, so a read that stopped translating, or translated
37+
* under the wrong key, names the view it got wrong.
38+
*/
39+
40+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
41+
import { spawn, execFile, type ChildProcessWithoutNullStreams } from 'node:child_process';
42+
import { existsSync, mkdtempSync, renameSync, rmSync, writeFileSync } from 'node:fs';
43+
import { tmpdir } from 'node:os';
44+
import { join } from 'node:path';
45+
import {
46+
CLI,
47+
E2E_SECRET_KEY,
48+
TSX,
49+
childEnv,
50+
portContentionError,
51+
portDriftError,
52+
probeThroughChild,
53+
randomPort,
54+
} from './helpers/serve-process.js';
55+
import { linkSpec } from './helpers/define-stack-fixture.js';
56+
57+
/** The banner's tail — every row above it has printed. */
58+
const READY = /Press Ctrl\+C to stop/;
59+
60+
const OBJ = 'acme_note';
61+
62+
/** One package, no `packages[]`; one container with a default list and one named list, and a catalog for both. */
63+
const CONFIG = `
64+
import { defineStack } from '@objectstack/spec';
65+
const grid = (label) => ({ type: 'grid', label, data: { provider: 'object', object: '${OBJ}' }, columns: ['title'] });
66+
const noteObj = { name: '${OBJ}', label: 'Note', sharingModel: 'private', fields: { title: { type: 'text', label: 'Title' } } };
67+
const noteViews = { object: '${OBJ}', list: grid('All Notes'), listViews: { mine: grid('My Notes') } };
68+
const app = { id: 'com.example.acme', name: 'acme', namespace: 'acme', version: '1.0.0', type: 'app' };
69+
const translations = [{
70+
en: { objects: { ${OBJ}: { label: 'Note', _views: { default: { label: 'All Notes' }, mine: { label: 'My Notes' } } } } },
71+
'zh-CN': { objects: { ${OBJ}: { label: '笔记', _views: { default: { label: '全部笔记' }, mine: { label: '我的笔记' } } } } },
72+
}];
73+
export default defineStack({ manifest: app, objects: [noteObj], views: [noteViews], translations }, { strict: false });
74+
`;
75+
76+
const dirs: string[] = [];
77+
const children: ChildProcessWithoutNullStreams[] = [];
78+
79+
function project(prefix: string): string {
80+
const dir = mkdtempSync(join(tmpdir(), prefix));
81+
dirs.push(dir);
82+
writeFileSync(join(dir, 'objectstack.config.ts'), CONFIG, 'utf8');
83+
linkSpec(dir);
84+
return dir;
85+
}
86+
87+
interface Run { code: number; stdout: string; stderr: string }
88+
89+
/** `os build` in `dir`, through the source entry. */
90+
function build(dir: string): Promise<Run> {
91+
return new Promise((done) => {
92+
execFile(
93+
TSX,
94+
[CLI, 'build'],
95+
// `childEnv`, never a bare `...process.env` — see its header (commit 1ddda1d00).
96+
{ cwd: dir, maxBuffer: 16 * 1024 * 1024, env: childEnv({ NO_COLOR: '1' }) },
97+
(err, stdout, stderr) => done({
98+
code: err ? (typeof (err as { code?: unknown }).code === 'number' ? (err as unknown as { code: number }).code : 1) : 0,
99+
stdout: String(stdout),
100+
stderr: String(stderr),
101+
}),
102+
);
103+
});
104+
}
105+
106+
/** `os build`, then take the config away so `os serve` boots the compiled artifact. */
107+
async function buildArtifact(dir: string): Promise<void> {
108+
const built = await build(dir);
109+
if (built.code !== 0 || !existsSync(join(dir, 'dist', 'objectstack.json'))) {
110+
throw new Error(`os build failed in ${dir} (exit ${built.code})\n${built.stdout}\n${built.stderr}`);
111+
}
112+
renameSync(join(dir, 'objectstack.config.ts'), join(dir, 'objectstack.config.ts.off'));
113+
}
114+
115+
interface LiveServe {
116+
child: ChildProcessWithoutNullStreams;
117+
output: () => string;
118+
}
119+
120+
/** Boot `os serve` in `dir` with `args`; the source entry seeds the dev admin either way. */
121+
function bootServe(dir: string, args: string[], port: string): Promise<LiveServe> {
122+
return new Promise((resolveBoot, rejectBoot) => {
123+
const child = spawn(TSX, [CLI, 'serve', ...args, '-p', port], {
124+
cwd: dir,
125+
stdio: ['pipe', 'pipe', 'pipe'],
126+
// `childEnv`, never a bare `...process.env` — see its header (commit 1ddda1d00).
127+
env: childEnv({
128+
NO_COLOR: '1',
129+
OS_DATABASE_URL: ':memory:',
130+
OS_LOG_LEVEL: 'warn',
131+
OS_DISABLE_CONSOLE: '1',
132+
OS_SECRET_KEY: E2E_SECRET_KEY,
133+
}),
134+
}) as ChildProcessWithoutNullStreams;
135+
children.push(child);
136+
137+
const what = `os serve ${args.join(' ')} in ${dir}`;
138+
let out = '';
139+
let settled = false;
140+
const settle = (err: Error | null) => {
141+
if (settled) return;
142+
settled = true;
143+
clearTimeout(timer);
144+
if (err) rejectBoot(err);
145+
else resolveBoot({ child, output: () => out });
146+
};
147+
const timer = setTimeout(
148+
() => settle(new Error(`${what} never printed ${READY}\n--- output ---\n${out.slice(-4000)}`)),
149+
180_000,
150+
);
151+
const onData = (d: unknown) => {
152+
out += String(d);
153+
// The child is the authority on the port it bound (#12525).
154+
if (READY.test(out)) settle(portDriftError(out, what, port));
155+
};
156+
child.stdout.on('data', onData);
157+
child.stderr.on('data', onData);
158+
child.on('exit', (code) =>
159+
settle(
160+
portContentionError(out, what, port)
161+
?? new Error(`${what} exited ${String(code)} before ${READY}\n--- output ---\n${out.slice(-4000)}`),
162+
),
163+
);
164+
});
165+
}
166+
167+
async function stop(child: ChildProcessWithoutNullStreams): Promise<void> {
168+
if (child.exitCode !== null || child.signalCode !== null) return;
169+
await new Promise<void>((done) => {
170+
const give = setTimeout(() => {
171+
try {
172+
child.kill('SIGKILL');
173+
} catch {
174+
/* already gone */
175+
}
176+
done();
177+
}, 10_000);
178+
child.once('exit', () => {
179+
clearTimeout(give);
180+
done();
181+
});
182+
try {
183+
child.kill('SIGTERM');
184+
} catch {
185+
clearTimeout(give);
186+
done();
187+
}
188+
});
189+
}
190+
191+
/** One HTTP exchange, attributed to the child if the transport fails (#15653). ⛔ No assertion inside. */
192+
async function request(serve: LiveServe, what: string, url: string, init: RequestInit = {}): Promise<{ status: number; body: any }> {
193+
return probeThroughChild(
194+
{
195+
child: serve.child,
196+
transcript: () => `\n--- child output ---\n${serve.output().slice(-4000)}`,
197+
label: 'serve-view-container-read',
198+
what,
199+
},
200+
async () => {
201+
const r = await fetch(url, init);
202+
const text = await r.text();
203+
let body: unknown = text;
204+
try { body = JSON.parse(text); } catch { /* keep the text */ }
205+
return { status: r.status, body };
206+
},
207+
);
208+
}
209+
210+
/** One by-name read, as a boot answered it. */
211+
interface Answer { status: number; item: any; error?: unknown }
212+
213+
/** What one boot answers for the container and for the two views it expands to, per locale. */
214+
type Reading = Record<string, Answer>;
215+
216+
const READS = [OBJ, `${OBJ}.default`, `${OBJ}.mine`];
217+
const LOCALES = ['zh-CN', 'en'];
218+
219+
/** Boot, sign in as the seeded admin, read every by-name view in every locale, stop. */
220+
async function read(dir: string, args: string[]): Promise<{ reading: Reading; output: string }> {
221+
const port = randomPort();
222+
const base = `http://localhost:${port}/api/v1`;
223+
const serve = await bootServe(dir, args, port);
224+
try {
225+
const signIn = await request(serve, 'the sign-in probe', `${base}/auth/sign-in/email`, {
226+
method: 'POST',
227+
headers: { 'content-type': 'application/json' },
228+
body: JSON.stringify({ email: 'admin@objectos.ai', password: 'admin123' }),
229+
});
230+
const token = signIn.body?.token;
231+
if (signIn.status !== 200 || typeof token !== 'string') {
232+
throw new Error(`sign-in answered ${signIn.status}: ${JSON.stringify(signIn.body)}\n--- output ---\n${serve.output().slice(-3000)}`);
233+
}
234+
const reading: Reading = {};
235+
for (const name of READS) {
236+
for (const locale of LOCALES) {
237+
const answer = await request(serve, `the read of view ${name} in ${locale}`, `${base}/meta/view/${name}`, {
238+
headers: { authorization: `Bearer ${token}`, 'accept-language': locale },
239+
});
240+
reading[`${name} ${locale}`] = {
241+
status: answer.status,
242+
item: answer.body?.item,
243+
...(answer.status === 200 ? {} : { error: answer.body }),
244+
};
245+
}
246+
}
247+
return { reading, output: serve.output() };
248+
} finally {
249+
await stop(serve.child);
250+
}
251+
}
252+
253+
/** The view content of a served container — what a translation can change, without the registrar's stamps. */
254+
function containerContent(item: any): Record<string, unknown> {
255+
const { object, label, list, listViews, form, formViews } = item ?? {};
256+
return { object, label, list, listViews, form, formViews };
257+
}
258+
259+
afterAll(async () => {
260+
for (const child of children) await stop(child);
261+
for (const dir of dirs) rmSync(dir, { recursive: true, force: true });
262+
}, 60_000);
263+
264+
describe('#22614 — the by-name read of a view container, on an artifact boot and on a config boot', () => {
265+
let config: { reading: Reading; output: string };
266+
let artifact: { reading: Reading; output: string };
267+
268+
beforeAll(async () => {
269+
config = await read(project('view-container-read-config-'), ['objectstack.config.ts']);
270+
const built = project('view-container-read-artifact-');
271+
await buildArtifact(built);
272+
artifact = await read(built, []);
273+
}, 480_000);
274+
275+
it.each([
276+
['the artifact boot', () => artifact],
277+
['the config boot', () => config],
278+
])('%s answers 200 for the container, its views translated for a zh-CN reader', (_door, boot) => {
279+
const { reading, output } = boot();
280+
const answer = reading[`${OBJ} zh-CN`];
281+
// Before the fix: the artifact boot answered 500 here.
282+
expect(answer.status, `${JSON.stringify(answer.error)}\n--- output ---\n${output.slice(-3000)}`).toBe(200);
283+
// Before the fix: the config boot served both in English.
284+
expect(answer.item.list.label).toBe('全部笔记');
285+
expect(answer.item.listViews.mine.label).toBe('我的笔记');
286+
// Before the fix: the config boot served a `label` equal to the object name.
287+
expect(answer.item).not.toHaveProperty('label');
288+
expect(output).not.toMatch(/reading 'startsWith'/);
289+
});
290+
291+
it.each([
292+
['the artifact boot', () => artifact],
293+
['the config boot', () => config],
294+
])('%s serves the authored labels to an en reader', (_door, boot) => {
295+
const answer = boot().reading[`${OBJ} en`];
296+
expect(answer.status, JSON.stringify(answer.error)).toBe(200);
297+
expect(answer.item.list.label).toBe('All Notes');
298+
expect(answer.item.listViews.mine.label).toBe('My Notes');
299+
});
300+
301+
it('the two boots serve the same container', () => {
302+
for (const locale of LOCALES) {
303+
expect(containerContent(config.reading[`${OBJ} ${locale}`].item), locale)
304+
.toEqual(containerContent(artifact.reading[`${OBJ} ${locale}`].item));
305+
}
306+
});
307+
308+
it('CONTROL — the by-name reads of the views it expands to are translated as before, alike on both boots', () => {
309+
const expected: Record<string, string> = {
310+
[`${OBJ}.default zh-CN`]: '全部笔记',
311+
[`${OBJ}.default en`]: 'All Notes',
312+
[`${OBJ}.mine zh-CN`]: '我的笔记',
313+
[`${OBJ}.mine en`]: 'My Notes',
314+
};
315+
for (const [key, label] of Object.entries(expected)) {
316+
for (const boot of [artifact, config]) {
317+
const answer = boot.reading[key];
318+
expect(answer.status, `${key}: ${JSON.stringify(answer.error)}`).toBe(200);
319+
expect(answer.item.label, key).toBe(label);
320+
}
321+
}
322+
});
323+
});

‎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:*",

0 commit comments

Comments
 (0)