Skip to content

Commit 9ad6544

Browse files
fix(spec,metadata-protocol): a view's packaged translation catalog yields to a published org overlay (#20731) (#20770)
Part of #20731. This PR lands the two producer halves that triage (5901376430) assigned to this card: the translator rule in `@objectstack/spec` and the packaged-view accessor in `@objectstack/metadata-protocol`. The card should stay open after this merges, because the `/meta` view arm in `packages/rest` still hands no base (see "Not in this PR"). Triage routes that arm onto #20730's per-type base resolver as one more arm. It is filed as a per-layer child, or added to #20730's PR, whichever the seats agree. Clause-②: yes ## Reproduced first, at the translator and protocol layer **Harness.** A scratch vitest file in `packages/objectql` (not committed). It builds a real `SchemaRegistry` and registers a `defineView` container the way the boot does: the container under the bare object key, then each `expandViewContainer` item under its qualified name with a package id. It uses the real `ObjectStackProtocolImplementation` over a read-only in-memory `sys_metadata` that holds one published org overlay row: `type: view`, `name: showcase_task.in_progress`, `package_id: null`, the org, `state: active`, label `In Progress (edited-20731)`. The catalog is keyed the way the showcase ships it (`objects.showcase_task._views.in_progress.label`, zh-CN `进行中`). I did not run a real `/meta` boot. The HTTP reading is the #20680 dev's probe3 (`os-dev-report` 5900307018), and this probe reproduces it one layer down. | reading | before (`origin/main` `33e4a5609c`) | after (this branch, rebuilt `dist`) | |---|---|---| | `protocol.getMetaItem` label (org read) | `In Progress (edited-20731)` | same | | `protocol.getMetaItems` label (org read) | `In Progress (edited-20731)` | same | | served `_packageId` | `com.example.showcase` (the artifact envelope) | same | | `typeof protocol.getPackagedViewBase` | `undefined` | `function` | | `translateView(served, zh-CN)`, no base | `进行中` | `进行中` (unchanged, by design) | | `translateView(served, zh-CN)`, handed the packaged view | **`进行中`** (the base was ignored) | **`In Progress (edited-20731)`** | | `translateMetadataDocument('view', …)` zh-CN, with base | `进行中` | `In Progress (edited-20731)` | | the same, en, with base | `In Progress (edited-20731)` | same | | unedited sibling `showcase_task.urgent`, zh-CN | `紧急` | `紧急` | | after reset (no overlay row): label, then zh-CN with base | `In Progress`, `进行中` | same | So the write and both protocol reads agree on one identity, and the protocol serves the edit. The catalog applied over it because `translateView` took no base: on `origin/main` its third parameter was `ResolveOptions`, and it never read `packagedBase`, even when the generic dispatcher passed one. ## Hypotheses (the dispatch's zone 2), and which held - **H1: held, with one correction.** `translateView` was at `i18n-resolver.ts:906` and `translateDashboard` at `:1407`. The comparison is not written inline in `translateDashboard`. PR #20728 already made it one private function, `valueOverridesPackagedBase`, behind the exported `scalarOverridesPackagedBase`, so there was nothing to extract. `translateView` now calls that same function, so there is no second comparison. The dashboard-specific piece was the part finder `packagedDashboardPart`, the lookup that finds the packaged counterpart of a widget, filter or option by key. It is renamed to `packagedPart`, its key union is widened, and both translators share it. `translateDashboard`'s behaviour is unchanged (its 9 pins and the whole spec suite are green). - **H2: held.** `getPackagedDashboardBase` was at `protocol.ts:7194`. The view base comes from the same source, `lookupArtifactItem`. Views are **top-level registry items**. The objectql boot loop registers each view that a `defineView` container expands to as its own `view` item, under the qualified name `OBJECT.VIEW_KEY`, with the package id; it also keeps the container under the bare object key. `objects.OBJECT._views.VIEW_KEY` is only the catalog's layout, not a registry shape. The base mirrors the registry item, which is also the shape the overlay row and both reads carry. A view has no extension fold, so the artifact item is the packaged declaration. - **H3: the key.** The catalog key is `objects.OBJECT._views.VIEW_KEY.label`, derived from the served name by `viewTranslationKey`, which strips the `OBJECT.` prefix. **The base is matched by the served view's registry identity, the qualified `OBJECT.VIEW_KEY` name**, never by the bare key. The bare key is unique only within one object; the showcase ships `in_progress` views on more than one object. Pinned in `protocol-packaged-view-base.test.ts`: `showcase_task.in_progress` and `showcase_project.in_progress` each answer their own base, and the bare `in_progress` answers `undefined`. At the translator, every pin hands a qualified-name served document whose catalog sits under the bare key. I did not enumerate the example apps for a view whose registered name differs from its container key beyond the prefix. The only producer of that shape is `expandViewContainer`'s collision rename, which the build-time view-ref lint turns into a hard error. - **H4: held, measured.** A scratch REST test (not committed) drove `GET /api/v1/meta/view/:name` and `GET /api/v1/meta/view` through `RestServer`'s real route handlers, against this branch's rebuilt `@objectstack/spec`. The protocol double served the overlaid view and implemented `getPackagedViewBase`. Results: item and list, zh-CN, `进行中`; `getPackagedViewBase` called **0** times. `packagedObjectBaseOf` in `meta-item-read-gate.ts` returns `undefined` for every type but `object`. So the defect stays observable over HTTP until the `/meta` view arm lands. ## What changes - `translateView` takes `TranslateDocumentOptions`; it previously took `ResolveOptions`, and the new type only adds the optional `packagedBase`. When a base is supplied, the catalog replaces a string only while that string still equals its packaged counterpart. The rule is ADR-0029 D9.2a's, and it is the same one `translateObject` (#8284) and `translateDashboard` follow. It covers: - the view `label` and `description`; - each bulk-action def's `label`, `confirmText` and `confirmLabel`; - each param's `label`, `help` and `placeholder`. A def or param is matched by the `name` the bundle addresses it by. One that the base does not carry counts as authored ("a base that declares no such scalar"). With no base, the output is byte-identical to before. - Why the bulk-action copy is included: `translateView` translates those strings too, and the rule belongs to the translator, not to one attribute. Leaving them out would mean that when the base is handed, an edited view label wins while an edited bulk-action label on the same view still loses. The measured reading is the label. The bulk-action cases are pinned but were not measured on a running app. - `ObjectStackProtocolImplementation.getPackagedViewBase(name)` is the view twin of `getPackagedDashboardBase`. Both now delegate to one private `packagedArtifactBase(type, name)`: `lookupArtifactItem` and nothing wider. It is shadow-immune to an overlay hydrated under the plain registry key. It answers `undefined` for a view no code package ships, for an unknown or empty name, and for a registry that cannot answer. `getPackagedDashboardBase` answers exactly as before (its 8 pins are green). ## Readers of the changed functions, and which served answers move - `translateView` is reached through `translateMetadataDocument('view', …)`. In the serving path, that means `packages/rest/src/meta-item-read-gate.ts`, where `translateMetaDocument` (item) and `translateMetaList` (list) pass `packagedBase: packagedObjectBaseOf(…)`. That is `undefined` for `view`, so no serving path supplies a view base today, and the new branch does not run in production until the `/meta` view arm lands. Measured: H4 above. The CLI's `i18n-bulk-action-coverage` test drives `translateView` without a base and is green (17 of 17). - `getPackagedViewBase` has no caller yet; the `/meta` view arm will be its first reader. - `packagedPart` and `valueOverridesPackagedBase` are private. ## Published surface (for the Clause-② line) - `@objectstack/spec`: no export is added, removed or renamed (`check:api-surface`: "public API surface + factory signatures unchanged"). One exported function's parameter type is widened: `translateView`'s `opts` goes from `ResolveOptions` to `TranslateDocumentOptions`, and every existing call still compiles. - `@objectstack/metadata-protocol`: one new public method, `getPackagedViewBase`, on the exported `ObjectStackProtocolImplementation`. - Both are widenings, so both changesets are `minor`, as the dispatch ordered: `.changeset/20731-spec-view-catalog-override.md` and `.changeset/20731-metadata-protocol-packaged-view-base.md`. ## No documented rule is reversed A value equal to the packaged declaration still gets the catalog, and so does a label written as an inline locale map, because only a string counts as an override (pinned). That keeps the case `content/docs/ui/translations.mdx` describes: a package's own label against its own bundle. ADR-0029 D9.2a is applied one type further; nothing is reversed. ## Tests The code and pins below are at `a3f753cead`, which is the fix plus a merge of `origin/main` `a51920f5fb`. The only later commit, `64f4797662`, adds one ledger row to `scripts/engine-double-contract.pinned.json`. The gates are at `64f4797662`. - `packages/spec/src/system/i18n-resolver.test.ts` gains a block of 12 tests: - the qualified-name vs bare-key identity; - an unedited view keeps every string translated; - an edited label is served in zh-CN and in `en`; - per-string judgement; - every string kind (description, the def's three strings, the param's three strings); - a tenant-added def or param; - no base as the pre-change control (the measured defect); - reset; - the ruled no-op edge and an inline locale-map label; - an unedited sibling view handed its own base; - no mutation of inputs; - the generic dispatcher. The whole spec `local` project: 576 files, 17003 passed, 1 todo. - `packages/objectql/src/protocol-packaged-view-base.test.ts` has 10 tests against a real `SchemaRegistry` and the real protocol: - the packaged body by qualified name; - immunity to a plain-key overlay; - the key (another object's same-key view; the bare key answers `undefined`); - the three `undefined` answers; - a registry that cannot answer; - item and list reads serve the overlay (one identity); - translation with the base serves the edit in zh-CN and `en`, on item and list; - an unedited view stays translated; - the no-base control reproduces the defect; - reset. The objectql `local` project: 339 files, 6714 tests, all green. - `@objectstack/metadata-protocol`: 190 files passed (3 skipped); 2792 tests passed (19 skipped). - `typecheck` is green for: - spec, with `check:scripts-typecheck` and its test layer; - metadata-protocol; - objectql, with `tsconfig.scripts.json` and its test layer. - **Reverse verification A** (spec, source-resolved, `scripts/ablation-replace.mjs`). The mutation fed `translateView` no base: `asRecord(opts?.packagedBase)` became `asRecord((opts as any)?.ABLATION_20731)`. The anchor went from 1 to 0, and the blob went from `1e7995c1148d` to `2f0943d33e2a`. `-t 20731` turned **5 of 12 red**: the edited label, every string kind, the tenant-added def or param, reset, and the dispatcher. It was restored with blob equal to HEAD and `git diff HEAD` empty. The per-string test stays green under this mutation by construction: it guards the other direction, withholding too much. - **Reverse verification B** (the same mutation, through `dist`). The spec was rebuilt, and `ablation-dist-preflight` found the marker in 4 built files. The objectql protocol pins turned **2 of 10 red**: translation with the base (item and list, zh-CN and en), and reset. Restore leg: restore, rebuild, `--absent` over 230 built files, tree clean, 10 of 10 green. - **Reverse verification C** (accessor, through metadata-protocol `dist`). `getPackagedViewBase` was made to answer the plain registry read, which is the shadow trap. After the rebuild the marker was in 2 built files, and **2 of 10** turned red: plain-key overlay immunity, and the no-code-package case. Restore leg: rebuild, `--absent` over 24 built files, tree clean, 10 of 10 green. - Gates: `node scripts/pm/dispatch-gates.mjs --commands` derived 96 families at `64f4797662`, and I ran all 96. Three first answered `PREREQUISITE NOT MET` because they needed built output: `check:dual-build-cjs-loads`, `check:lean-entry-closure` and `check:type-check-debt`. They exited 0 after `turbo run build` over `./packages/*` and `./packages/*/*`. `--ran` reconciliation: "96 derived, 96 run, 0 NOT-MEASURED, 0 UNRUN". `check:engine-double-contract` asked for the new double to be recorded, so `scripts/engine-double-contract.pinned.json` gains one `findOne` row, written by the gate's `--write`. - Narrowed lint: `eslint --no-inline-config --format json` over the 4 TypeScript files in the diff reported 4 files, 0 errors and 0 warnings. That population comes from the config's own `files` glob, `**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}`; the other 3 changed paths are `.md` and `.json`. The config never enables type-aware linting (its own comment says so: no `parserOptions.project`), so this diff cannot move the verdict for an untouched file. ## Not in this PR - **The `/meta` view arm (H4).** `packages/rest`'s item and list reads still pass no view base, so `GET /api/v1/meta/view/showcase_task.in_progress` and `GET /api/v1/meta/view` in `zh-CN` keep serving `进行中` over a published overlay until that arm lands. Triage routes it onto #20730's per-type resolver as one more arm, calling `getPackagedViewBase(name)` with the served qualified name. It is not a second resolver. #20730 is not addressed here. - **Docs.** `content/docs/ui/translations.mdx` should state the view rule once the rule is observable, next to #20730's dashboard sentence. - **The console.** I did not measure whether the console re-resolves `_views` labels client-side over the served label (the dashboard twin's acceptance notes flag that shape for widget titles). Its carrier is the `/meta` view arm's browser verification. ## Acceptance notes - **Dormant until the `/meta` arm.** With this PR alone, no served answer changes. Measured at the REST route layer (H4), not in a real boot. - The branch merged `origin/main` at `a51920f5fb` before this PR opened. Since then, `main` has taken 3 commits (`cba417a8f2`, `0d9349fea0`, `7053333e1e`), and none of them touches these packages. The merge queue rebuilds on the current `main`. --- _Generated by [Claude Code](https://claude.ai/code/session_01DEvba2nBuD4tWzfq8r8NFY)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent a3d7588 commit 9ad6544

7 files changed

Lines changed: 658 additions & 27 deletions

File tree

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
'@objectstack/metadata-protocol': minor
3+
---
4+
5+
fix(metadata-protocol): `getPackagedViewBase(name)` answers the view a code package ships, before any overlay (#20731)
6+
7+
`ObjectStackProtocolImplementation` gains `getPackagedViewBase(name)`, the view twin of `getPackagedDashboardBase`. It returns the packaged (code-layer) declaration of a view, which is the `packagedBase` that `translateView` compares a served view against, so that a tenant's published overlay is not overwritten by the packaged translation catalog.
8+
9+
`name` is the view's registry identity, the qualified `<object>.<viewKey>` that each view of a `defineView` container is registered under and that the overlay row and both reads carry. The bare view key that the catalog uses under its object is not an identity (another object may ship a view with the same key), and it answers `undefined`.
10+
11+
It reads the artifact registry's code-package entry only, through the same lookup as `getPackagedDashboardBase`. An overlay that was hydrated under the plain registry key can therefore never be returned as the base it is compared against. It returns `undefined` for a view that no code package ships, for an unknown or empty name, and for a registry that cannot answer. A caller treats `undefined` as "no base known", and the catalog applies as before.
12+
13+
The method is additive. No existing read changes answer, and `getPackagedDashboardBase` answers exactly as before. Nothing is removed or renamed.
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+
fix(spec): `translateView` lets an explicit override beat the packaged catalog when it is handed the packaged view (#20731)
6+
7+
`translateView` (`@objectstack/spec/system`) now follows the rule ADR-0029 D9.2a records for objects, and that `translateDashboard` already follows: an explicit override beats a packaged default. When the caller supplies `packagedBase` (the view as its code package ships it, before any tenant overlay), a catalog string replaces a served string only while that string still equals its packaged counterpart. It is the same comparison, not a second one.
8+
9+
The comparison is made per string: the view `label` and `description`, each bulk-action def's `label`, `confirmText` and `confirmLabel`, and each of its params' `label`, `help` and `placeholder`. A def or param is matched by the `name` the bundle addresses it by, and one that the packaged view does not carry counts as authored, so its strings keep their values.
10+
11+
Why: an org overlay on a packaged view (ADR-0126 Regime O) changed the label of the showcase's `showcase_task.in_progress` and published. The metadata protocol's item and list reads served the edit, but a `zh-CN` reader was served `进行中`, the catalog's translation of the label the package shipped. The catalog (`objects.<object>._views.<viewKey>`) translated the packaged view and was applied over the tenant's edit.
12+
13+
What changes for a caller:
14+
15+
- `translateView`'s third parameter is typed `TranslateDocumentOptions` (was `ResolveOptions`). That type is `ResolveOptions` plus the optional `packagedBase`, and `translateMetadataDocument` already passed it through. Every existing call compiles unchanged.
16+
- Without `packagedBase` (`undefined` or `null`), the output is byte-identical to before: the catalog applies. No serving layer in this release passes a view base yet, so no served view changes answer with this package alone.
17+
- An edit back to exactly the shipped string is a no-op: the catalog still translates it. A label written as an inline locale map is not an override either; only a string is compared.
18+
19+
Nothing is removed or renamed, and there is nothing to migrate.

‎packages/metadata-protocol/src/protocol.ts‎

Lines changed: 52 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7192,12 +7192,62 @@ export class ObjectStackProtocolImplementation implements
71927192
* unknown name, and a registry double without the lookup.
71937193
*/
71947194
getPackagedDashboardBase(name: string): unknown {
7195+
return this.packagedArtifactBase('dashboard', name);
7196+
}
7197+
7198+
/**
7199+
* [#20731] The PACKAGED (code-layer) declaration of a VIEW — the view
7200+
* item a code package ships, before any tenant `sys_metadata` overlay.
7201+
* The view twin of {@link getPackagedDashboardBase}, answered the same
7202+
* way, and the fact `translateView` (`@objectstack/spec/system`) compares
7203+
* against as its `packagedBase`: the i18n catalog
7204+
* (`objects.<object>._views.<viewKey>`) is the packaged translation of
7205+
* exactly this body, so it may only replace a string the served view
7206+
* still carries unchanged from it (ADR-0029 D9.2a — an explicit override
7207+
* beats a packaged default; ADR-0126 Regime O — a packaged view is
7208+
* overlay-editable, and a published overlay is what every read serves).
7209+
*
7210+
* Measured before this existed: an org overlay on the showcase's
7211+
* `showcase_task.in_progress` changed its label, published, and was
7212+
* returned by {@link getMetaItem} and {@link getMetaItems}, yet a `zh-CN`
7213+
* reader was served the catalog's translation of the shipped label,
7214+
* because the translation of the served view had no base to compare with.
7215+
*
7216+
* `name` is the served view's REGISTRY identity — the qualified
7217+
* `<object>.<viewKey>` the boot registers each view of a `defineView`
7218+
* container under (`expandViewContainer`), which is also the name the
7219+
* overlay row and both reads carry. ⛔ Not the bare `<viewKey>` the catalog
7220+
* is keyed by under its object: that key is unique only within one object,
7221+
* so it can never select the base (it answers `undefined` here unless some
7222+
* code package registers a view under that literal name).
7223+
*
7224+
* Shadow-immune for the same reason as the dashboard's: an overlay hydrated
7225+
* under the plain registry key is never returned as the base it is meant
7226+
* to be compared against. A view has no extension fold, so the artifact
7227+
* item IS the packaged declaration.
7228+
*
7229+
* Returns `undefined` for a runtime/tenant-authored view (no code package
7230+
* ships it), an unknown or empty name, and a registry double without the
7231+
* lookup — "no packaged baseline", read by the translator as the catalog
7232+
* applying unchanged.
7233+
*/
7234+
getPackagedViewBase(name: string): unknown {
7235+
return this.packagedArtifactBase('view', name);
7236+
}
7237+
7238+
/**
7239+
* The one body behind {@link getPackagedDashboardBase} and
7240+
* {@link getPackagedViewBase}: {@link lookupArtifactItem}, and nothing
7241+
* wider, so only an item whose `_packageId` marks a genuine code package
7242+
* answers, and an overlay hydrated under the plain registry key never does.
7243+
*/
7244+
private packagedArtifactBase(type: 'dashboard' | 'view', name: string): unknown {
71957245
if (typeof name !== 'string' || name === '') return undefined;
71967246
try {
7197-
return this.lookupArtifactItem('dashboard', name);
7247+
return this.lookupArtifactItem(type, name);
71987248
} catch {
71997249
// A read over the in-memory registry; a failure here must never
7200-
// turn a served dashboard into a 5xx.
7250+
// turn a served document into a 5xx.
72017251
return undefined;
72027252
}
72037253
}
Lines changed: 257 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,257 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* #20731 — `ObjectStackProtocolImplementation.getPackagedViewBase`, against a
5+
* REAL `SchemaRegistry`, and the read it exists to feed. The view twin of
6+
* `protocol-packaged-dashboard-base.test.ts`.
7+
*
8+
* ## What was measured
9+
*
10+
* An org overlay on the showcase's packaged view `showcase_task.in_progress`
11+
* (ADR-0126 Regime O — a packaged view is overlay-editable) changed its label
12+
* and was published. An `en` reader was served the edit; a `zh-CN` reader,
13+
* admin or member, on the `/meta` item and list reads, was served `进行中` —
14+
* the catalog's translation of the label the package shipped. Reproduced here
15+
* one layer down, on the tree before this change: the protocol's item and list
16+
* reads both return the overlay's label, and `translateView` answered `进行中`
17+
* over it in `zh-CN` even when handed the packaged view, because it took no
18+
* base at all.
19+
*
20+
* The rule that fixes it is ADR-0029 D9.2a's — an explicit override beats a
21+
* packaged default, decided by comparing against the PACKAGED declaration —
22+
* and that comparison is only as good as the body this accessor returns. Hence
23+
* a real registry, registered the way the boot registers a `defineView`
24+
* container: the container under the bare object key, and each view it
25+
* expands to (`expandViewContainer`) under its qualified `<object>.<viewKey>`
26+
* name, which is the identity the base is looked up by.
27+
*/
28+
29+
import { describe, it, expect } from 'vitest';
30+
import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol';
31+
import { translateView, type TranslationBundle } from '@objectstack/spec/system';
32+
import { expandViewContainer } from '@objectstack/spec/ui';
33+
import { SchemaRegistry } from './registry.js';
34+
import { assertEngineFindOnePredicate } from './engine-findone-predicate.js';
35+
36+
const PKG = 'com.example.showcase';
37+
const OBJ = 'showcase_task';
38+
const VIEW = 'showcase_task.in_progress';
39+
const ORG = 'org_acme';
40+
41+
const SHIPPED_LABEL = 'In Progress';
42+
const EDITED_LABEL = 'In Progress (edited)';
43+
44+
const listView = (object: string, label: string) => ({
45+
label,
46+
type: 'grid' as const,
47+
data: { provider: 'object' as const, object },
48+
columns: [{ field: 'title' }],
49+
});
50+
51+
/** The `defineView` container as the code package ships it. */
52+
const taskContainer = () => ({
53+
listViews: {
54+
in_progress: listView(OBJ, SHIPPED_LABEL),
55+
urgent: listView(OBJ, 'Urgent'),
56+
},
57+
});
58+
59+
/**
60+
* The catalog: `zh-CN` translates the shipped labels, keyed by the BARE view
61+
* key under its object; `en` repeats the shipped label, the way a package that
62+
* extracts its source locale ships it.
63+
*/
64+
const BUNDLE: TranslationBundle = {
65+
en: { objects: { [OBJ]: { _views: { in_progress: { label: SHIPPED_LABEL }, urgent: { label: 'Urgent' } } } } } as any,
66+
'zh-CN': { objects: { [OBJ]: { _views: { in_progress: { label: '进行中' }, urgent: { label: '紧急' } } } } } as any,
67+
};
68+
69+
const matchesWhere = (row: Record<string, unknown>, where: Record<string, unknown>) =>
70+
Object.entries(where ?? {}).every(([k, v]) => {
71+
if (k.startsWith('$')) throw new Error(`fake driver: unsupported operator ${k}`);
72+
if (v === null) return row[k] === null || row[k] === undefined;
73+
return row[k] === v;
74+
});
75+
76+
/**
77+
* A real registry and the real protocol over a READ-ONLY in-memory
78+
* `sys_metadata`: the overlay row is seeded exactly as a published org overlay
79+
* stores it, so no write verb is doubled here at all.
80+
*/
81+
function makeSession() {
82+
const registry = new SchemaRegistry({ multiTenant: false });
83+
registry.logLevel = 'silent';
84+
const rows: Record<string, unknown>[] = [];
85+
const engine: any = {
86+
registry,
87+
async findOne(table: string, o: { where: Record<string, unknown> }) {
88+
// The real engine's #4419 predicate: a `findOne` naming no record is refused.
89+
assertEngineFindOnePredicate(table, o);
90+
if (table !== 'sys_metadata') return null;
91+
return rows.find((r) => matchesWhere(r, o.where)) ?? null;
92+
},
93+
async find(table: string, o: { where: Record<string, unknown>; limit?: number }) {
94+
if (table !== 'sys_metadata') return [];
95+
const matched = rows.filter((r) => matchesWhere(r, o.where));
96+
// The caller's bound, applied after the filter, by presence.
97+
return typeof o?.limit === 'number' ? matched.slice(0, o.limit) : matched;
98+
},
99+
};
100+
const protocol: any = new ObjectStackProtocolImplementation(engine, undefined, 'env_test');
101+
return { protocol, registry, rows };
102+
}
103+
104+
/** Register a container the way the boot does: the bare object key, then every expanded view. */
105+
function registerContainer(registry: SchemaRegistry, object: string, container: Record<string, unknown>, pkg: string) {
106+
registry.registerItem('view', { name: object, ...container }, 'name', pkg);
107+
for (const item of expandViewContainer(object, container)) {
108+
registry.registerItem('view', item, 'name', pkg);
109+
}
110+
}
111+
112+
/** The packaged view item, as the boot registered it — the overlay's starting point. */
113+
const packagedItem = () => {
114+
const item = expandViewContainer(OBJ, taskContainer()).find((v) => v.name === VIEW);
115+
if (!item) throw new Error(`fixture missing ${VIEW}`);
116+
return item;
117+
};
118+
119+
/** The same view as the org's published overlay stores it — its label edited. */
120+
const overlayBody = () => ({ ...packagedItem(), label: EDITED_LABEL });
121+
122+
/** The published org overlay row, in the identity the write stores. */
123+
function seedOrgOverlay(rows: Record<string, unknown>[]) {
124+
rows.push({
125+
id: 'r_1',
126+
type: 'view',
127+
name: VIEW,
128+
package_id: null,
129+
organization_id: ORG,
130+
state: 'active',
131+
metadata: JSON.stringify(overlayBody()),
132+
});
133+
}
134+
135+
describe('#20731 getPackagedViewBase — the packaged view, never an overlay', () => {
136+
it('answers the view item the code package shipped, by its qualified name', () => {
137+
const s = makeSession();
138+
registerContainer(s.registry, OBJ, taskContainer(), PKG);
139+
const base = s.protocol.getPackagedViewBase(VIEW);
140+
expect(base?.name).toBe(VIEW);
141+
expect(base?.object).toBe(OBJ);
142+
expect(base?.label).toBe(SHIPPED_LABEL);
143+
expect(base?._packageId).toBe(PKG);
144+
});
145+
146+
it('is immune to an overlay hydrated under the plain registry key', () => {
147+
// A published overlay is hydrated into the registry's PLAIN key. The
148+
// accessor must still answer the packaged body — a base that returned
149+
// the overlay would compare the edit equal to itself and hand the
150+
// catalog straight back.
151+
const s = makeSession();
152+
registerContainer(s.registry, OBJ, taskContainer(), PKG);
153+
s.registry.registerItem('view', overlayBody(), 'name');
154+
155+
// The trap is live: the plain registry read answers the overlay…
156+
expect((s.registry.getItem('view', VIEW) as any)?.label).toBe(EDITED_LABEL);
157+
// …and the packaged base does not move.
158+
expect(s.protocol.getPackagedViewBase(VIEW)?.label).toBe(SHIPPED_LABEL);
159+
});
160+
161+
it('the KEY — matched by the qualified <object>.<viewKey>, never by the bare catalog key another object shares', () => {
162+
// The catalog addresses a view by its bare key UNDER its object, so the
163+
// bare key alone is not an identity: `showcase_project` ships an
164+
// `in_progress` view too. The base must be this object's view.
165+
const s = makeSession();
166+
registerContainer(s.registry, OBJ, taskContainer(), PKG);
167+
registerContainer(s.registry, 'showcase_project', { listViews: { in_progress: listView('showcase_project', 'Active Projects') } }, PKG);
168+
169+
expect(s.protocol.getPackagedViewBase(VIEW)?.label).toBe(SHIPPED_LABEL);
170+
expect(s.protocol.getPackagedViewBase('showcase_project.in_progress')?.label).toBe('Active Projects');
171+
// The bare key selects nothing — no view is registered under it.
172+
expect(s.protocol.getPackagedViewBase('in_progress')).toBeUndefined();
173+
});
174+
175+
it('is undefined for a view no code package ships, an unknown name, and an empty name', () => {
176+
// "No packaged baseline" is a real answer: the translator reads it as
177+
// "infer nothing" and keeps the catalog, as before this change.
178+
const s = makeSession();
179+
s.registry.registerItem('view', { ...packagedItem(), name: 'showcase_task.tenant_view' }, 'name');
180+
expect(s.protocol.getPackagedViewBase('showcase_task.tenant_view')).toBeUndefined();
181+
expect(s.protocol.getPackagedViewBase('showcase_task.no_such_view')).toBeUndefined();
182+
expect(s.protocol.getPackagedViewBase('')).toBeUndefined();
183+
});
184+
185+
it('is undefined when the host registry cannot answer', () => {
186+
const partial: any = new ObjectStackProtocolImplementation({ registry: {} } as any, undefined, 'env_test');
187+
expect(partial.getPackagedViewBase(VIEW)).toBeUndefined();
188+
const none: any = new ObjectStackProtocolImplementation({} as any, undefined, 'env_test');
189+
expect(none.getPackagedViewBase(VIEW)).toBeUndefined();
190+
});
191+
});
192+
193+
describe('#20731 the published org overlay, through the protocol reads and the translation', () => {
194+
it('the item and list reads serve the overlay — one identity for the write and both reads', async () => {
195+
const s = makeSession();
196+
registerContainer(s.registry, OBJ, taskContainer(), PKG);
197+
seedOrgOverlay(s.rows);
198+
const served = await s.protocol.getMetaItem({ type: 'view', name: VIEW, organizationId: ORG });
199+
expect(served.item.label).toBe(EDITED_LABEL);
200+
// The code artifact's envelope rides on the overlay body; it names the
201+
// registering package, not a second identity the overlay missed.
202+
expect(served.item._packageId).toBe(PKG);
203+
204+
const list = await s.protocol.getMetaItems({ type: 'view', organizationId: ORG });
205+
const listed = (list.items as any[]).find((v) => v?.name === VIEW);
206+
expect(listed?.label).toBe(EDITED_LABEL);
207+
});
208+
209+
it('the translation serves the edit once it is handed the packaged base — zh-CN and en, item and list', async () => {
210+
const s = makeSession();
211+
registerContainer(s.registry, OBJ, taskContainer(), PKG);
212+
seedOrgOverlay(s.rows);
213+
const item = (await s.protocol.getMetaItem({ type: 'view', name: VIEW, organizationId: ORG })).item;
214+
const listed = ((await s.protocol.getMetaItems({ type: 'view', organizationId: ORG })).items as any[])
215+
.find((v) => v?.name === VIEW);
216+
217+
for (const served of [item, listed]) {
218+
const packagedBase = s.protocol.getPackagedViewBase(served.name);
219+
for (const locale of ['zh-CN', 'en']) {
220+
expect(translateView(served, BUNDLE, { locale, packagedBase }).label, locale).toBe(EDITED_LABEL);
221+
}
222+
}
223+
});
224+
225+
it('an unedited view stays translated, handed its own base', async () => {
226+
const s = makeSession();
227+
registerContainer(s.registry, OBJ, taskContainer(), PKG);
228+
seedOrgOverlay(s.rows);
229+
const urgent = (await s.protocol.getMetaItem({ type: 'view', name: 'showcase_task.urgent', organizationId: ORG })).item;
230+
const packagedBase = s.protocol.getPackagedViewBase('showcase_task.urgent');
231+
expect(packagedBase?.label).toBe('Urgent');
232+
expect(translateView(urgent, BUNDLE, { locale: 'zh-CN', packagedBase }).label).toBe('紧急');
233+
});
234+
235+
it('CONTROL — without the base, the catalog replaces the edit (the measured defect)', async () => {
236+
const s = makeSession();
237+
registerContainer(s.registry, OBJ, taskContainer(), PKG);
238+
seedOrgOverlay(s.rows);
239+
const served = (await s.protocol.getMetaItem({ type: 'view', name: VIEW, organizationId: ORG })).item;
240+
expect(translateView(served, BUNDLE, { locale: 'zh-CN' }).label).toBe('进行中');
241+
expect(translateView(served, BUNDLE, { locale: 'en' }).label).toBe(SHIPPED_LABEL);
242+
});
243+
244+
it('after reset (no overlay row) the shipped label is served and translated again', async () => {
245+
const s = makeSession();
246+
registerContainer(s.registry, OBJ, taskContainer(), PKG);
247+
seedOrgOverlay(s.rows);
248+
const edited = (await s.protocol.getMetaItem({ type: 'view', name: VIEW, organizationId: ORG })).item;
249+
const packagedBase = s.protocol.getPackagedViewBase(VIEW);
250+
expect(translateView(edited, BUNDLE, { locale: 'zh-CN', packagedBase }).label).toBe(EDITED_LABEL);
251+
252+
s.rows.length = 0;
253+
const served = (await s.protocol.getMetaItem({ type: 'view', name: VIEW, organizationId: ORG })).item;
254+
expect(served.label).toBe(SHIPPED_LABEL);
255+
expect(translateView(served, BUNDLE, { locale: 'zh-CN', packagedBase }).label).toBe('进行中');
256+
});
257+
});

0 commit comments

Comments
 (0)