Skip to content

Commit 87d0619

Browse files
feat(spec): the app item read answers by package id — 403 / 404 by the package key, per ADR-0048 §3.1 (#23468)
Fixes #23183 Clause-②: yes ## What this does ADR-0048 §3.1: "The route/container coordinate for an installed package's UI is its **package id** … With the current **one-app-per-package** invariant, the app *is* the package container." The console therefore opens an app at `/apps/PACKAGE_ID`. When that app is not in the caller's list, it must tell a withheld app (403) from a missing one (404). By name, the item read already answers that. By package id, no declared read did: `GET /api/v1/meta/app/com.acme.payroll` asked for an app NAMED `com.acme.payroll` and answered 404 whatever the package held. The existing app item read now accepts a package id as its key, for `type: app` only, and answers the verdict the by-name read gives: | caller / key | answer | | :--- | :--- | | a member on a withheld app's package id | `403 PERMISSION_DENIED`, the by-name read's verdict (the message echoes the key the caller sent, never the app name) | | a holder | `200`, the app under its own `name`, the same item the by-name read serves | | an unknown package, a package with no app, a package whose app is unpublished (ADR-0045 §3), a package with more than one app | `404`, byte-identical to the answer for an unknown app name | | a `packageId` / `?package=` naming a different package than the key | `400 INVALID_REQUEST` | | a key in the package-id grammar on any other type | read by name, unchanged | No separate verdict endpoint, no other type gains the arm, and the list read's withheld-drop is unchanged. ## Where it lands, and why it is not `rest-server.ts` The card expected `GetMetaItemRequestSchema` plus `packages/rest`'s app item read. Measurement moved the runtime half to the producer, which the card's clause allows ("若实测表明真正的生产者在别包,直接按生产者侧修"): - **Spec** (`packages/spec/src/api/protocol.zod.ts`): `GetMetaItemRequestSchema.name` declares the arm. The new export `isAppPackageIdKey(type, name)` (`type === 'app'` and `MANIFEST_ID_PATTERN`) is its one spelling. `GetMetaItemRequestSchema` is "still the protocol METHOD contract" (`plugin-rest-api.zod.ts`, the `GET /:type/:name` route entry), the input of `getMetaItem(request)` (`protocol.zod.ts`, the protocol interface). - **Producer** (`packages/metadata-protocol/src/protocol.ts`, `resolveAppPackageIdKey`, called once at the top of `getMetaItem`): the key resolves to the package's one app, read by its own name scoped to that package. Two transports serve `GET /meta/:type/:name`: `RestServer`, and the runtime dispatcher behind `@objectstack/hono`'s catch-all. Both hand the protocol the key they were asked for and judge the envelope through the same `createMetaItemAnswer` chain (`meta-item-read-gate.ts`). The arm therefore reaches both with no transport code, and the per-caller gate judges the resolved app exactly as by name. A REST-only resolution would have left the catch-all host without the arm, and made the spec declaration false on the protocol face it declares. - **REST** (`packages/rest/src/rest-server.ts`, `fetchCurrentMetaDocument`): `/history`, `/audit` and `/diff` judge "the item the plain read would judge" through `getMetaItem`, but read their own data under the literal `:name`. Without a guard they would judge the resolved app while serving the key's (empty) events. That gives a non-holder a 403 where history used to answer 200, and a holder a 200 empty diff where `/diff` used to answer the 404 absence. The guard asks `isAppPackageIdKey` and keeps those doors by name, as before. `meta-item-read-gate.ts` is untouched. "The package's app" is what the package-scoped list serves (`getMetaItems({ type: 'app', packageId })`, the read behind `GET /meta/app?package=`). That covers the apps the package ships and its stored rows, and nothing for a disabled package. Drafts are in view exactly when the read reads drafts (`state: 'draft'` or `previewDrafts`). ### Readings the PM asked for - **Zero apps:** 404 (the absence answer). - **Two or more apps:** 404, plus one `console.warn` per package and app set naming the package and its apps with the remedy. One-app-per-package is enforced only by `defineStack` for `manifest.type === 'app'` (`validateSingleApp`, ADR-0019 D3). The shipped Setup/Studio/Account packages are `type: 'plugin'`, and the `/meta` save door does not enforce it either. A distinct "two apps" answer was rejected: it would let a caller see an app being built in the package (unpublished, ADR-0045 §3) before it is published. ## Premise verification (the ruling's falsifiable premise) 1. **Disjoint grammars: holds.** `AppSchema.name` is `SnakeCaseIdentifierSchema` (`/^[a-z][a-z0-9_]*$/`, `ui/app.zod.ts`, `shared/identifiers.zod.ts`): no dot. `MANIFEST_ID_PATTERN` (`kernel/manifest.zod.ts`) requires at least one dot. Every mint path holds it: the `/meta` save door refuses a body whose `name` differs from the URL name (`savedItemNameRefusal`, 400) and then parses `AppSchema` (422), and `defineStack` parses `AppSchema`. A dotted app name is therefore never legal. 2. **No wider disclosure: holds.** Withheld gives 403 `PERMISSION_DENIED`, the by-name verdict, and its message echoes the caller's own key, so the package-to-app-name mapping is not disclosed. Unpublished, service-gated, unknown, no-app and multi-app all give the same 404 bytes; the wire test pins unpublished against unknown on both hosts. The by-name control `/meta/app/payroll` answers the same 403 to the same member. ## For the objectui consumer (objectstack-ai/objectui#12285) Probe the declared read `GET /api/v1/meta/app/PACKAGE_ID`, i.e. `client.meta.getItem('app', packageId)`, declared by `GetMetaItemRequestSchema.name` (recognise the key with `isAppPackageIdKey`). The existing `probeMetaItemAccess` mapping reads it unchanged: `403 PERMISSION_DENIED` maps to `denied`, the 404 with the absence code maps to `not_found`, and a `200` carrying `item` maps to `granted`. The `200` envelope's `name` is the app's own name. ## Tests - `packages/metadata-protocol/src/protocol.app-package-id-key.test.ts` (new, producer): covers one app (singular and plural type spelling), the by-name read, no app / unknown package, more than one app (absence plus one report), a `packageId` naming another package (`code` + `status`), the grammar on another type (discriminating: a package of that very id ships an app), and drafts. - `packages/runtime/src/domains/meta-app-package-id-key.test.ts` (new, wire): `RestServer` and the hono catch-all, each over its own real store (better-sqlite3 `:memory:`, real `sys_metadata*`, real protocol). The triage pins run on both hosts: member 403 equals the by-name 403; holder 200 equals the by-name item; unknown package id 404 equals unknown app name bytes; unpublished package equals unknown package bytes; dotted key on `view` read by name. RestServer-only rows: `/history` and `/audit` stay by name for a builder who does not hold the app, and a holder's `/diff` stays the absence answer. ## Local readings Every build and test went through the shared verify lock, on a shared box. **At `d8f08c015e`, the final head, after merging `origin/main`.** Main's incoming commits touch none of the read paths below; §10's scoped re-check: - `pnpm --filter @objectstack/metadata-protocol exec vitest run src/protocol.app-package-id-key.test.ts`: 8/8. - `pnpm --filter @objectstack/runtime exec vitest run --project local` over six files: 6 files, 182/182. The files are `meta-app-package-id-key`, `meta-item-read-gate-parity`, `meta-package-all-fold`, `meta-item-query-decision`, `meta-published-runtime-publish` and `meta-save-actor`. - `pnpm --filter @objectstack/spec exec vitest run --project local src/api src/type-alias-convention.pin.test.ts`: 50 files, 1583/1583. - `pnpm --filter @objectstack/spec check:generated`: all 14 generated artifacts up to date. The api-surface delta is 0 breaking, 1 added (`isAppPackageIdKey`). **At `7b8b2dfc8f`, before the merge:** - `pnpm --filter @objectstack/metadata-protocol exec vitest run`: 236 files passed, 3 skipped (28393 tests). - `pnpm --filter @objectstack/rest exec vitest run --project local`: 281/281 files. - The full run passed 280. `export-integration.test.ts` failed at import with a missing module (`@objectstack/platform-objects/apps`), because another process of mine was rebuilding `platform-objects/dist` at that moment. - Re-run alone, it passed 26/26. - `typecheck`: - spec, metadata-protocol and rest are green, and runtime's `tsc --noEmit` is green. - runtime's test layer (`check:test-typecheck`) reports 19 findings, all in test files this diff does not touch. Among them is TS7016 on `@objectstack/service-messaging` declarations, which is this worktree's build state; none is in this diff's test. NOT MEASURED here: CI's `TypeScript Type Check` reads it on a fresh build. - **Declared narrowing:** the full `@objectstack/spec` and `@objectstack/runtime` suites are left to CI (`Test Core`). Locally I ran the targeted sets above. **Ablations.** One per behaviour this diff pins, each through `scripts/ablation-replace.mjs`. In both, the mutation landed (anchor 1 to 0, blob changed) and the restore is proven (blob equals HEAD, `git diff HEAD` empty). Both subjects resolve from `src`, with no `dist/` in the path. - **A, the producer's arm short-circuited:** `protocol.app-package-id-key.test.ts` went 5 failed / 3 passed. - Red: the one-app, plural-spelling, multi-app report, packageId-conflict and drafts rows. - Green: the by-name, absence and other-type rows, as predicted. - **B, the REST guard deleted:** `meta-app-package-id-key.test.ts` went 2 failed / 10 passed. `/history` answered the builder `403 PERMISSION_DENIED`, and `/diff` answered the holder a `200` empty diff: the two predicted reds. Every plain-read row on both hosts stayed green. **Gates.** `node scripts/pm/dispatch-gates.mjs --commands` derived 122 families from this diff at `d8f08c015e`, and all of them ran locally after the final commit. 118 exited 0. Four are NOT MEASURED: - `check:skill-examples` and `check:dual-build-cjs-loads`: exit 3, PREREQUISITE NOT MET (packages not built in this worktree). - `check:dts-closure`: exit 1, naming packages this worktree never built. It reads the tree after a closure build, which only CI performs. - `check:type-check-debt`: my 560s timeout fired before it reached a verdict. The `--ran` reconciliation reads: 122 derived, 118 run, 4 NOT-MEASURED, 0 UNRUN. The local-only `check:dev-prereqs` finds `packages/spec/dist` fresh. It names one unrelated package dist without a build stamp (`@objectstack/organizations`). Every family CI runs is read from this PR's check-runs, not re-run here. ## Acceptance notes - **Residue boundary.** The read doors stay open to pre-grammar residue rows by design (`METADATA_ITEM_NAME_PATTERN`'s note). A stored `app` row whose name is in the package-id grammar could only be residue, and the item read would now read that key as a package id. The row stays listable and deletable. Absent a named producer, not filed. - **Disabled package.** The package-scoped list hides a disabled package's app, so the package-id key answers 404 for it. The by-name read does not consult the disabled flag. This is noted, not changed here. - **Layered view.** `?layers=true` and `/layers` read through `getMetaItemLayered`, not `getMetaItem`, so they do not gain the arm, consistent with the ruling's "the existing app item read". - **Two `rest-server.ts` comments routed to this dispatch** (seat 4's clock-out ledger). Both are outside this diff's touched region, so neither is changed here: - the `NormalizedRestServerConfig.api.documentation` comment names `documentation.enabled` and `responseFormat` as the `retiredKey()` tombstones but not `documentation.version`, which `rest-server.zod.ts` also tombstones, so it reads partially stale; - the `parseDeclaredApiConfig` paragraph was not re-measured beyond its location. - **Declared file-surface deviation.** The claim's surface named `GetMetaItemRequestSchema` only in `protocol.zod.ts`, and `rest-server.ts` / `meta-item-read-gate.ts` for runtime. This diff also adds: - an import line and the `isAppPackageIdKey` export next to the schema, disjoint from #23428's describes; - the producer in `@objectstack/metadata-protocol`, under the card's clause; - a test in `@objectstack/runtime` that holds both hosts equal. In `rest-server.ts` it touches `fetchCurrentMetaDocument`, not the plain read handler. --- _Generated by [Claude Code](https://claude.ai/code/session_0121ooBYRmS8ziFXm9ai5YHm)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent d54e239 commit 87d0619

10 files changed

Lines changed: 699 additions & 2 deletions

File tree

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
---
2+
'@objectstack/spec': minor
3+
'@objectstack/metadata-protocol': minor
4+
'@objectstack/rest': patch
5+
---
6+
7+
feat(spec): the app item read answers by package id. `GET /meta/app/<packageId>` returns the package's app, or the same 403 / 404 the read by name gives (ADR-0048 §3.1)
8+
9+
Clause-②: yes (widening)
10+
11+
ADR-0048 §3.1 makes an installed package's id the route key of its UI. With one app per package the app is the package container, so the console opens an app at `/apps/<packageId>`. The app item read now accepts that key too.
12+
13+
- **`GetMetaItemRequestSchema.name`** declares it. For `type: 'app'` only, a key in the package-id grammar (`MANIFEST_ID_PATTERN`, at least one dot) names the package. App names are snake_case with no dot, so the two grammars never overlap and every read by name is unchanged. The new export `isAppPackageIdKey(type, name)` is the one test for the arm.
14+
- **`getMetaItem`** (`@objectstack/metadata-protocol`) resolves it to the package's one app, as the package-scoped list (`GET /meta/app?package=<id>`) serves it, read by its own name in that package. The response's `name` is the app's name. Both hosts' item reads (`RestServer` and the `@objectstack/hono` catch-all) answer it with no change of their own, and the per-caller read gate judges the app exactly as it does by name:
15+
- a caller the app is withheld from gets `403 PERMISSION_DENIED`;
16+
- an unknown package, a package with no app, a package whose app is unpublished, and a package holding more than one app all get the `404` absence answer, byte-identical to the one for an unknown app name. A package holding more than one app (ADR-0019 D3 allows at most one) is also reported once in the server log, naming its apps;
17+
- a caller who holds the app is served it.
18+
- A `packageId` (`?package=`) naming a different package than the key is refused with `400 INVALID_REQUEST`.
19+
- `/meta/app/<key>/history`, `/audit` and `/diff` stay by name (`@objectstack/rest`): the package-id key belongs to the plain item read only.
20+
21+
From a client: `client.meta.getItem('app', 'com.acme.crm')`.

‎content/docs/references/api/protocol.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1424,7 +1424,7 @@ This schema accepts one of the following structures:
14241424
| Property | Type | Required | Description |
14251425
| :--- | :--- | :--- | :--- |
14261426
| **type** | `string` | ✅ | Metadata type name |
1427-
| **name** | `string` | ✅ | Item name (snake_case identifier) |
1427+
| **name** | `string` | ✅ | Item name: the item's own `name`. For `type: 'app'` only, it may instead be the id of the package that ships the app (`manifest.id`, reverse-domain, e.g. `com.acme.crm`): with one app per package the app IS the package container, and the package id is its route key (ADR-0048 §3.1). The two grammars are disjoint (an app name is snake_case with no dot; a package id matches `MANIFEST_ID_PATTERN`, with at least one dot), so an app key in the package-id grammar always names the package (`isAppPackageIdKey`). That read answers the package's one app as the by-name read answers it, under the app's own `name`, and the per-caller read gate judges it as it judges that app read by name (an app withheld from the caller answers 403 `PERMISSION_DENIED`). A package with no app, or with more than one, answers as an absent item. A `packageId` naming a different package is refused with 400 `INVALID_REQUEST`. Every other type, and every other key, is read by name. |
14281428
| **packageId** | `string` | optional | Optional package ID to filter items by |
14291429
| **member** | `string` | optional | Bundle member selector, for a metadata type whose identity is a pair (today only `email_template`, identified by `(name, locale)`): the member this request addresses, spelled as its discriminator value (`zh-CN`). Absent, the canonical member (`en-US` for `email_template`), which is what a bare-name read answers; naming the canonical value is the same request. It is not the response translation locale, which `?locale=` and `Accept-Language` choose. Refused with 400 `VALIDATION_ERROR` on a type that has no members, and for a blank value or one longer than a stored member can be (100 characters). A well-formed member with nothing stored or shipped answers as that door answers an absent item. |
14301430
| **state** | `Enum<'active' \| 'draft'>` | optional | Draft-visibility switch — which lifecycle row to read (strict mode): `'draft'` opens the pending draft buffer (Studio's editor read) and fails when no draft exists; absent or `'active'` reads the live published row. Distinct from `previewDrafts`, which FALLS BACK to the active row when no draft exists. Declaration ≠ authorization: this member only selects which stored row is read — ADR-0106 masking is unaffected, and draft access is gated upstream, not by this schema. |
Lines changed: 217 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,217 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#23183, ADR-0048 §3.1] `getMetaItem` answers the app read keyed by PACKAGE
5+
* ID, as `GetMetaItemRequestSchema.name` declares.
6+
*
7+
* ADR-0048 §3.1 makes the package id the app's route key ("With the current
8+
* one-app-per-package invariant, the app *is* the package container"), so the
9+
* console addresses an app at `/apps/<packageId>`. No declared read answered an
10+
* app by that key: `/meta/app/com.acme.crm` asked for an app NAMED
11+
* `com.acme.crm` and was answered absence whether the package's app existed,
12+
* was withheld, or was missing.
13+
*
14+
* The producer resolves the key once (`resolveAppPackageIdKey`), so both
15+
* transports' item reads reach it with no code of their own; their wire
16+
* answers are pinned in `@objectstack/runtime`'s
17+
* `meta-app-package-id-key.test.ts`. This file pins the producer:
18+
*
19+
* 1. one app in the package: the package's app, under its own name, the same
20+
* item the by-name read serves (the plural type spelling included);
21+
* 2. the by-name read is unchanged;
22+
* 3. no app, an unknown package, and more than one app: absence (the last
23+
* told to the operator once, never answered differently);
24+
* 4. a `packageId` naming a different package: `400 INVALID_REQUEST`;
25+
* 5. a key in the package-id grammar on another type: read by name;
26+
* 6. drafts are in view exactly when the read reads drafts.
27+
*
28+
* The engine double is `protocol.list-slot-prefer-local.test.ts`'s: `find` /
29+
* `findOne` over `sys_metadata` rows, and a registry answering a
30+
* package-scoped lookup with that package's items only.
31+
*/
32+
import { afterEach, describe, expect, it, vi } from 'vitest';
33+
import { assertEngineFindOnePredicate, isCodeArtifactBody } from '@objectstack/metadata-core';
34+
import { ObjectStackProtocolImplementation } from './protocol.js';
35+
36+
const CRM_PKG = 'com.acme.crm';
37+
const CRM_APP = { name: 'crm', label: 'CRM', navigation: [], _packageId: CRM_PKG, _provenance: 'package' };
38+
/** A package that breaks ADR-0019 D3's at-most-one rule. */
39+
const SUITE_PKG = 'com.acme.suite';
40+
const SUITE_APPS = [
41+
{ name: 'suite_sales', label: 'Sales', navigation: [], _packageId: SUITE_PKG, _provenance: 'package' },
42+
{ name: 'suite_service', label: 'Service', navigation: [], _packageId: SUITE_PKG, _provenance: 'package' },
43+
];
44+
/** A package that ships no app. */
45+
const LIB_PKG = 'com.acme.lib';
46+
/** A package whose only app is a pending draft row — authored, never published. */
47+
const DRAFT_PKG = 'com.acme.drafted';
48+
const DRAFT_APP_NAME = 'drafted';
49+
/**
50+
* A `view` whose name is in the package-id grammar (`<object>.<viewKey>`), and
51+
* a package of that very id shipping an app: if the arm fired on `view`, the
52+
* read would resolve that package and miss the view.
53+
*/
54+
const VIEW_KEY = 'account.pipeline';
55+
const VIEW_ITEM = { name: VIEW_KEY, label: 'Pipeline', object: 'account', viewKind: 'list', _packageId: 'com.acme.views', _provenance: 'package' };
56+
const SHADOW_APP = { name: 'pipeline_app', label: 'Pipeline app', navigation: [], _packageId: VIEW_KEY, _provenance: 'package' };
57+
58+
interface StoredRow {
59+
id: string;
60+
type: string;
61+
name: string;
62+
organization_id: string | null;
63+
package_id: string | null;
64+
state: string;
65+
metadata: string;
66+
}
67+
68+
const STORED: StoredRow[] = [{
69+
id: 'r_drafted_draft',
70+
type: 'app',
71+
name: DRAFT_APP_NAME,
72+
organization_id: null,
73+
package_id: DRAFT_PKG,
74+
state: 'draft',
75+
metadata: JSON.stringify({ name: DRAFT_APP_NAME, label: 'Drafted', navigation: [] }),
76+
}];
77+
78+
function harness() {
79+
const items: Array<Record<string, unknown> & { name: string }> = [CRM_APP, ...SUITE_APPS, VIEW_ITEM, SHADOW_APP];
80+
const typeOf = (item: Record<string, unknown>) => (item === VIEW_ITEM ? 'view' : 'app');
81+
const ofType = (asked: string, packageId?: string) =>
82+
items.filter((i) => typeOf(i) === asked && (!packageId || i._packageId === packageId));
83+
const registry = {
84+
getArtifactItem(asked: string, name: string, packageId?: string) {
85+
const hit = ofType(asked, packageId).find((i) => i.name === name);
86+
return hit && isCodeArtifactBody(hit) ? hit : undefined;
87+
},
88+
getItem(asked: string, name: string, packageId?: string) {
89+
return ofType(asked, packageId).find((i) => i.name === name);
90+
},
91+
listItems(asked: string, packageId?: string) {
92+
return ofType(asked, packageId);
93+
},
94+
getObject: () => undefined,
95+
registerObject: () => undefined,
96+
getPackage: () => undefined,
97+
isPackageDisabled: () => false,
98+
applyNavContributions: (app: unknown) => app,
99+
};
100+
const matching = (where: Record<string, unknown>) => {
101+
for (const k of Object.keys(where)) {
102+
if (k.startsWith('$')) throw new Error(`[test double] unsupported WHERE combinator '${k}'`);
103+
}
104+
return STORED.filter((r) =>
105+
Object.entries(where).every(([k, v]) => v === undefined || (r as unknown as Record<string, unknown>)[k] === v),
106+
);
107+
};
108+
const engine: any = {
109+
async find(table: string, opts?: { where?: Record<string, unknown>; limit?: number }) {
110+
if (table !== 'sys_metadata') return [];
111+
const matched = matching(opts?.where ?? {});
112+
// `check:objectql-double-limit` — the caller's bound, applied after the filter.
113+
return opts?.limit === undefined ? matched : matched.slice(0, opts.limit);
114+
},
115+
async findOne(table: string, opts?: { where?: Record<string, unknown> }) {
116+
// `check:engine-double-contract` — refuses what the real engine refuses.
117+
assertEngineFindOnePredicate(table, opts);
118+
if (table !== 'sys_metadata') return null;
119+
return matching(opts?.where ?? {})[0] ?? null;
120+
},
121+
async insert() {
122+
return {};
123+
},
124+
registry,
125+
};
126+
return new ObjectStackProtocolImplementation(engine, () => new Map(), 'env_1');
127+
}
128+
129+
/** The envelope's item, with nothing but its identity read off it. */
130+
const itemName = (envelope: any): unknown => envelope?.item?.name;
131+
132+
afterEach(() => {
133+
vi.restoreAllMocks();
134+
});
135+
136+
describe('[#23183] getMetaItem answers an app keyed by its package id (ADR-0048 §3.1)', () => {
137+
it('one app in the package: that app, under its own name — the item the by-name read serves', async () => {
138+
const protocol = harness();
139+
const byPackage: any = await protocol.getMetaItem({ type: 'app', name: CRM_PKG });
140+
const byName: any = await protocol.getMetaItem({ type: 'app', name: 'crm' });
141+
142+
expect(byPackage.type).toBe('app');
143+
// The envelope names the app by its own name: the address a save of it
144+
// uses, never the key it was asked by.
145+
expect(byPackage.name).toBe('crm');
146+
expect(itemName(byPackage)).toBe('crm');
147+
expect(byPackage.item._packageId).toBe(CRM_PKG);
148+
expect(byPackage.item).toEqual(byName.item);
149+
});
150+
151+
it('the plural type spelling is folded first, so `apps` reaches the arm too', async () => {
152+
const protocol = harness();
153+
const envelope: any = await protocol.getMetaItem({ type: 'apps', name: CRM_PKG });
154+
expect(envelope.type).toBe('app');
155+
expect(itemName(envelope)).toBe('crm');
156+
});
157+
158+
it('the by-name read is unchanged: an app name is never in the package-id grammar', async () => {
159+
const protocol = harness();
160+
const envelope: any = await protocol.getMetaItem({ type: 'app', name: 'crm' });
161+
expect(envelope.name).toBe('crm');
162+
expect(itemName(envelope)).toBe('crm');
163+
});
164+
165+
it('a package that ships no app, and a package nobody installed: absence', async () => {
166+
const protocol = harness();
167+
for (const key of [LIB_PKG, 'com.acme.missing']) {
168+
const envelope: any = await protocol.getMetaItem({ type: 'app', name: key });
169+
expect(envelope.item, key).toBeUndefined();
170+
}
171+
});
172+
173+
it('a package holding more than one app: absence, and the operator is told once, naming the package and its apps', async () => {
174+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
175+
const protocol = harness();
176+
177+
const first: any = await protocol.getMetaItem({ type: 'app', name: SUITE_PKG });
178+
const second: any = await protocol.getMetaItem({ type: 'app', name: SUITE_PKG });
179+
expect(first.item).toBeUndefined();
180+
expect(second.item).toBeUndefined();
181+
182+
const reports = warn.mock.calls.map((args) => String(args[0])).filter((line) => line.includes(SUITE_PKG));
183+
expect(reports).toHaveLength(1);
184+
for (const app of SUITE_APPS) expect(reports[0]).toContain(app.name);
185+
// Each app stays readable by its own name.
186+
for (const app of SUITE_APPS) {
187+
expect(itemName(await protocol.getMetaItem({ type: 'app', name: app.name }))).toBe(app.name);
188+
}
189+
});
190+
191+
it('a packageId naming a different package is refused 400 INVALID_REQUEST; the same package is the same read', async () => {
192+
const protocol = harness();
193+
await expect(protocol.getMetaItem({ type: 'app', name: CRM_PKG, packageId: SUITE_PKG }))
194+
.rejects.toMatchObject({ code: 'INVALID_REQUEST', status: 400 });
195+
196+
const same: any = await protocol.getMetaItem({ type: 'app', name: CRM_PKG, packageId: CRM_PKG });
197+
expect(itemName(same)).toBe('crm');
198+
});
199+
200+
it('a key in the package-id grammar on another type is read by name, even where a package of that id ships an app', async () => {
201+
const protocol = harness();
202+
const envelope: any = await protocol.getMetaItem({ type: 'view', name: VIEW_KEY });
203+
expect(envelope.name).toBe(VIEW_KEY);
204+
expect(itemName(envelope)).toBe(VIEW_KEY);
205+
});
206+
207+
it('drafts are in view exactly when the read reads drafts: a draft-only app resolves on the draft read, not on the plain one', async () => {
208+
const protocol = harness();
209+
210+
const draft: any = await protocol.getMetaItem({ type: 'app', name: DRAFT_PKG, state: 'draft' });
211+
expect(draft.name).toBe(DRAFT_APP_NAME);
212+
expect(itemName(draft)).toBe(DRAFT_APP_NAME);
213+
214+
const plain: any = await protocol.getMetaItem({ type: 'app', name: DRAFT_PKG });
215+
expect(plain.item).toBeUndefined();
216+
});
217+
});

0 commit comments

Comments
 (0)