Skip to content

Commit 245be87

Browse files
committed
metadata-protocol: pin getMetaItem's package-id key for app
One app, the by-name read, no app / unknown / two apps (absence, one operator report), a packageId naming another package (400 INVALID_REQUEST), the grammar on another type, and drafts. Claude-Session: https://claude.ai/code/session_0121ooBYRmS8ziFXm9ai5YHm Co-authored-by: Claude <noreply@anthropic.com>
1 parent 7e62280 commit 245be87

1 file changed

Lines changed: 217 additions & 0 deletions

File tree

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)