Skip to content

Commit dd85dd1

Browse files
committed
refactor(metadata-protocol): move the artifact registry's lookups out of protocol.ts (#25467 cut 15)
Move-only. The bodies of lookupArtifactItem, artifactLockLayerAt, shippedArtifactsOf and shippingPackagesOf move into the new protocol-artifact-lookup.ts (host shape B: a live engine getter, and protocol-bound closures for lookupArtifactItem, shippingPackagesOf and shippedArtifactsOf, so the moved bodies reach each other through the class methods). All four members keep their name, docblock and signature in the class as stubs that delegate. resolveArtifactLockLayer leaves protocol.ts' import with its last reader. Nothing re-exports the new module, so the published surface does not move. The #22024 call-site pin scans the new module beside protocol.ts: the lookup's body and shippedArtifactsOf's two calls of it are recorded there, the stub's host closure is recorded as a new site, and a non-vacuity case checks that the seam module's sites are found. Claude-Session: https://claude.ai/code/session_016SP1spj613tyGCgxoD1An6 Co-authored-by: Claude <noreply@anthropic.com>
1 parent 3117289 commit dd85dd1

3 files changed

Lines changed: 231 additions & 87 deletions

File tree

Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#25467 cut 15] The artifact registry's lookups of
5+
* `ObjectStackProtocolImplementation` (ADR-0010 metadata protection): the
6+
* artifact an installed code package registered for an item
7+
* (`lookupArtifactItem`), every package's artifact for it
8+
* (`shippedArtifactsOf`), the packages that can ship an item of a type
9+
* (`shippingPackagesOf`), and the item-lock resolution's artifact layer over
10+
* them (`artifactLockLayerAt`), moved out of `protocol.ts` whole.
11+
*
12+
* A MOVE, not a redesign. Each function below is the body of the
13+
* `ObjectStackProtocolImplementation` method of the same name as it stood,
14+
* re-indented one level and otherwise unedited: the same registry reads, in
15+
* the same order, answering the same artifacts.
16+
*
17+
* ## How the moved bodies reach the class
18+
*
19+
* The shape #23050 set for `rest-server.ts` (its `data-action-routes.ts`
20+
* header carries the full reasoning), as `protocol-batch-data.ts` applies it.
21+
* The class keeps every method — the published `dist/*.d.ts` lists each one's
22+
* name, docblock and declared signature — and each body is one literal of
23+
* exactly the {@link ArtifactLookupHost} members that method reads, plus a
24+
* `.call`. The engine is handed over as a getter, so the body reads the
25+
* protocol's engine at call time. A method is handed over as a closure that
26+
* calls it on the protocol at call time, so a test double or spy set on the
27+
* instance is still the one the body reaches. That holds between the four
28+
* functions here too: `shippedArtifactsOf` reaches the lookup and the
29+
* shipping-package read through the class methods, not by calling the
30+
* functions below directly. Every other reader of these four methods keeps
31+
* calling the class method.
32+
*
33+
* #22024's call-site pin (`protocol.lookup-artifact-item-call-sites.test.ts`)
34+
* scans this module beside `protocol.ts`: the lookup's definition and
35+
* `shippedArtifactsOf`'s two calls of it live here now.
36+
*
37+
* What the bodies read at module level they import from where `protocol.ts`
38+
* imported it; `ShippingPackages` is `protocol.ts`'s own exported type.
39+
* Nothing re-exports this module, so the published surface does not move.
40+
*/
41+
42+
import { PLURAL_TO_SINGULAR } from '@objectstack/spec/shared';
43+
import { resolveArtifactLockLayer, type ItemAddress } from './item-lock.js';
44+
import type { ObjectStackProtocolImplementation, ShippingPackages } from './protocol.js';
45+
46+
/**
47+
* The `ObjectStackProtocolImplementation` members the functions below read —
48+
* every one of them, and no other; each function's `this` is the `Pick` of the
49+
* members IT reads. Each is typed AS the class member (an indexed access on
50+
* the class), so a change to a member's signature on the class is a change
51+
* here too, and naming a member the class does not have is a compile error.
52+
*/
53+
export interface ArtifactLookupHost {
54+
readonly engine: ObjectStackProtocolImplementation['engine'];
55+
lookupArtifactItem: ObjectStackProtocolImplementation['lookupArtifactItem'];
56+
shippingPackagesOf: ObjectStackProtocolImplementation['shippingPackagesOf'];
57+
shippedArtifactsOf: ObjectStackProtocolImplementation['shippedArtifactsOf'];
58+
}
59+
60+
/** The body of `ObjectStackProtocolImplementation#lookupArtifactItem`; the method's docblock there is its contract. */
61+
export function lookupArtifactItem(
62+
this: Pick<ArtifactLookupHost, 'engine'>,
63+
type: string, name: string, currentPackageId?: string, member?: string,
64+
): unknown {
65+
const registry = (this.engine as any)?.registry;
66+
if (!registry) return undefined;
67+
const singular = PLURAL_TO_SINGULAR[type] ?? type;
68+
// Prefer the artifact-only lookup: it scans composite
69+
// (`<packageId>:<name>`) entries first, so an overlay row hydrated
70+
// into the plain key (getMetaItems / loadMetaFromDb) can never
71+
// shadow the packaged artifact's protection envelope (ADR-0010
72+
// §3.3 — pre-fix, that shadow made a `_lock: full` app read back
73+
// as unlocked after PUT+GET until restart). `currentPackageId`
74+
// (ADR-0048) makes that scan package-scoped (prefer-local).
75+
if (typeof registry.getArtifactItem === 'function') {
76+
return registry.getArtifactItem(singular, name, currentPackageId, member)
77+
?? registry.getArtifactItem(type, name, currentPackageId, member);
78+
}
79+
// Partial registry mocks in tests — fall back to getItem and apply
80+
// the same package-provenance filter inline.
81+
if (typeof registry.getItem !== 'function') return undefined;
82+
const item = registry.getItem(singular, name, currentPackageId) ?? registry.getItem(type, name, currentPackageId);
83+
if (!item || !(item as any)._packageId || (item as any)._packageId === 'sys_metadata') {
84+
return undefined;
85+
}
86+
return item;
87+
}
88+
89+
/** The body of `ObjectStackProtocolImplementation#artifactLockLayerAt`; the method's docblock there is its contract. */
90+
export function artifactLockLayerAt(
91+
this: Pick<ArtifactLookupHost, 'shippedArtifactsOf'>,
92+
address: ItemAddress, shipping?: ShippingPackages,
93+
): readonly unknown[] {
94+
return resolveArtifactLockLayer(address, () => this.shippedArtifactsOf(address.type, address.name, shipping));
95+
}
96+
97+
/** The body of `ObjectStackProtocolImplementation#shippedArtifactsOf`; the method's docblock there is its contract. */
98+
export function shippedArtifactsOf(
99+
this: Pick<ArtifactLookupHost, 'lookupArtifactItem' | 'shippingPackagesOf'>,
100+
type: string, name: string, shipping?: ShippingPackages,
101+
): unknown[] {
102+
const shipped: unknown[] = [];
103+
const add = (artifact: unknown): void => {
104+
if (artifact !== undefined && artifact !== null && !shipped.includes(artifact)) shipped.push(artifact);
105+
};
106+
add(this.lookupArtifactItem(type, name));
107+
if ((PLURAL_TO_SINGULAR[type] ?? type) === 'object') return shipped;
108+
const packages = shipping ?? this.shippingPackagesOf(type);
109+
for (const packageId of new Set([...(packages.byName.get(name) ?? []), ...packages.anyName])) {
110+
const own = this.lookupArtifactItem(type, name, packageId) as { _packageId?: unknown } | undefined;
111+
if (own?._packageId === packageId) add(own);
112+
}
113+
return shipped;
114+
}
115+
116+
/** The body of `ObjectStackProtocolImplementation#shippingPackagesOf`; the method's docblock there is its contract. */
117+
export function shippingPackagesOf(
118+
this: Pick<ArtifactLookupHost, 'engine'>,
119+
type: string,
120+
): ShippingPackages {
121+
const registry = (this.engine as any)?.registry;
122+
const byName = new Map<string, Set<string>>();
123+
const anyName = new Set<string>();
124+
if (!registry) return { byName, anyName };
125+
const isPackage = (id: unknown): id is string => typeof id === 'string' && id !== '' && id !== 'sys_metadata';
126+
const listing = (read: () => unknown): readonly unknown[] => {
127+
try {
128+
const listed = read();
129+
return Array.isArray(listed) ? listed : [];
130+
} catch {
131+
return []; // See this method's header: the package-less lookup still answers.
132+
}
133+
};
134+
if (typeof registry.listItems === 'function') {
135+
for (const spelling of new Set([PLURAL_TO_SINGULAR[type] ?? type, type])) {
136+
for (const entry of listing(() => registry.listItems(spelling))) {
137+
const { _packageId: id, name } = (entry ?? {}) as { _packageId?: unknown; name?: unknown };
138+
if (!isPackage(id)) continue;
139+
if (typeof name !== 'string') {
140+
anyName.add(id);
141+
continue;
142+
}
143+
const ids = byName.get(name);
144+
if (ids) ids.add(id);
145+
else byName.set(name, new Set([id]));
146+
}
147+
}
148+
}
149+
if (typeof registry.getAllPackages === 'function') {
150+
const canTell = typeof registry.isPackageDisabled === 'function';
151+
for (const record of listing(() => registry.getAllPackages())) {
152+
const r = record as { manifest?: { id?: unknown }; id?: unknown } | null | undefined;
153+
const id = r?.manifest?.id ?? r?.id;
154+
if (isPackage(id) && (!canTell || registry.isPackageDisabled(id))) anyName.add(id);
155+
}
156+
}
157+
return { byName, anyName };
158+
}

‎packages/metadata-protocol/src/protocol.lookup-artifact-item-call-sites.test.ts‎

Lines changed: 50 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -22,26 +22,46 @@
2222
*
2323
* The population is the card's own enumeration command,
2424
* `git grep -n 'lookupArtifactItem(' -- packages/metadata-protocol/src`
25-
* (tests excluded), which is one file: every match is a call or the
26-
* definition. Each is keyed by the class member it sits in and its argument
27-
* text, which survive line moves and fail on any change of scope. A new call,
28-
* a removed one or a re-scoped one turns this red until its row is recorded
29-
* here with its disposition (and the card's table in the PR that adds it).
25+
* (tests excluded), which is two files: every match is a call or a
26+
* definition. Each is keyed by the file, the class member or module function
27+
* it sits in, and its argument text, which survive line moves and fail on any
28+
* change of scope. A new call, a removed one or a re-scoped one turns this red
29+
* until its row is recorded here with its disposition (and the card's table in
30+
* the PR that adds it).
31+
*
32+
* [#25467 cut 15] The second file is `protocol-artifact-lookup.ts`. The
33+
* lookup's body moved there with `shippedArtifactsOf`'s, so it holds the
34+
* function the class member delegates to and that member's two calls; the
35+
* class member keeps its signature in `protocol.ts`, and `shippedArtifactsOf`'s
36+
* stub there hands the moved body the lookup as a closure, one more site.
3037
*/
3138
import { readFileSync } from 'node:fs';
3239
import { fileURLToPath } from 'node:url';
3340
import { describe, expect, it } from 'vitest';
3441

3542
const SOURCE = readFileSync(fileURLToPath(new URL('./protocol.ts', import.meta.url)), 'utf8');
43+
/** [#25467 cut 15] The seam module the lookup's body moved to; scanned with its own header shape. */
44+
const SEAM_FILE = 'protocol-artifact-lookup.ts';
45+
const SEAM = readFileSync(fileURLToPath(new URL(`./${SEAM_FILE}`, import.meta.url)), 'utf8');
3646
const CALLEE = 'lookupArtifactItem(';
3747

3848
/** A class member's header in `protocol.ts`: four spaces, optional modifiers, its name, its parameter list. */
3949
const MEMBER_HEADER = /^ {4}(?:(?:private|public|protected|static|async|override|readonly)\s+)*([A-Za-z_$][\w$]*)\s*(?:<[^>]*>)?\(/;
50+
/** A module function's header in the seam module: `export`, optional `async`, `function`, its name, its parameter list. */
51+
const FUNCTION_HEADER = /^export (?:async )?function ([A-Za-z_$][\w$]*)\s*(?:<[^>]*>)?\(/;
52+
53+
interface Site { readonly file?: string; readonly member: string; readonly args: string }
4054

41-
interface Site { readonly member: string; readonly args: string }
55+
/** Every `lookupArtifactItem(` in `protocol.ts` and the seam module: the file, the member it sits in and its argument text, whitespace-normalised. */
56+
function sitesIn(): Site[] {
57+
return [
58+
...sitesOf(SOURCE, MEMBER_HEADER),
59+
...sitesOf(SEAM, FUNCTION_HEADER).map((site) => ({ ...site, file: SEAM_FILE })),
60+
];
61+
}
4262

43-
/** Every `lookupArtifactItem(` in the source: the member it sits in and its argument text, whitespace-normalised. */
44-
function sitesOf(source: string): Site[] {
63+
/** Every `lookupArtifactItem(` in one source: the member it sits in (by `header`) and its argument text, whitespace-normalised. */
64+
function sitesOf(source: string, header: RegExp): Site[] {
4565
const lines = source.split('\n');
4666
const sites: Site[] = [];
4767
for (let at = source.indexOf(CALLEE); at !== -1; at = source.indexOf(CALLEE, at + 1)) {
@@ -53,8 +73,8 @@ function sitesOf(source: string): Site[] {
5373
const args = source.slice(at + CALLEE.length, end - 1).replace(/\s+/g, ' ').trim().replace(/,$/, '');
5474
let member = '(no member)';
5575
for (let line = source.slice(0, at).split('\n').length - 1; line >= 0; line--) {
56-
const header = MEMBER_HEADER.exec(lines[line]);
57-
if (header) { member = header[1]; break; }
76+
const found = header.exec(lines[line]);
77+
if (found) { member = found[1]; break; }
5878
}
5979
sites.push({ member, args });
6080
}
@@ -70,7 +90,9 @@ type Disposition = 'definition' | 'served-package envelope' | 'no';
7090
*/
7191
const SITES: ReadonlyArray<Site & { readonly noPackage: boolean; readonly disposition: Disposition; readonly why: string }> = [
7292
{ member: 'lookupArtifactItem', args: 'type: string, name: string, currentPackageId?: string, member?: string', noPackage: false, disposition: 'definition',
73-
why: 'the definition itself' },
93+
why: 'the definition itself: the class member\'s declared signature (its body delegates to the seam function below)' },
94+
{ file: SEAM_FILE, member: 'lookupArtifactItem', args: 'this: Pick<ArtifactLookupHost, \'engine\'>, type: string, name: string, currentPackageId?: string, member?: string', noPackage: false, disposition: 'definition',
95+
why: '[#25467 cut 15] the definition\'s body, the function the class member delegates to' },
7496
{ member: 'readMetaItem', args: 'request.type, request.name, envelopePackageId(request.packageId, item), bundleMemberOf(request.type, item)', noPackage: true, disposition: 'served-package envelope',
7597
why: 'the by-name read\'s envelope: naming no package, the package of the item it serves (the list\'s rule); [#23074] at the served item\'s own bundle member' },
7698
{ member: 'readFlattenedMetaItems', args: 'request.type, itemName, itemPackageId, bundleMemberOf(request.type, it)', noPackage: true, disposition: 'served-package envelope',
@@ -91,10 +113,12 @@ const SITES: ReadonlyArray<Site & { readonly noPackage: boolean; readonly dispos
91113
why: 'a boolean containment test over the one owner of an object' },
92114
{ member: 'packagedArtifactOwner', args: 'folded.type, folded.name', noPackage: true, disposition: 'no',
93115
why: 'a shipped-package check by name: it returns a package id, never an envelope on a served body' },
94-
{ member: 'shippedArtifactsOf', args: 'type, name', noPackage: true, disposition: 'no',
116+
{ file: SEAM_FILE, member: 'shippedArtifactsOf', args: 'type, name', noPackage: true, disposition: 'no',
95117
why: 'the lock\'s artifact layer (the one item-lock resolution), never an envelope on a served body' },
96-
{ member: 'shippedArtifactsOf', args: 'type, name, packageId', noPackage: false, disposition: 'no',
118+
{ file: SEAM_FILE, member: 'shippedArtifactsOf', args: 'type, name, packageId', noPackage: false, disposition: 'no',
97119
why: 'scoped to a named package, and kept only when it is that package\'s own' },
120+
{ member: 'shippedArtifactsOf', args: '...a', noPackage: true, disposition: 'no',
121+
why: '[#25467 cut 15] the class member\'s host closure: it forwards the moved body\'s two calls (the rows above) unchanged, so its disposition is theirs' },
98122
{ member: 'hydrateOverlayIntoRegistry', args: 'type, (data as any).name, options.packageId ?? undefined, bundleMemberOf(type, data)', noPackage: true, disposition: 'no',
99123
why: 'scoped to the row\'s own package; undefined only for a package-less row, whose body names no package; [#23074] at the row\'s own bundle member' },
100124
{ member: 'expandRuntimeViewContainer', args: 'type, String(item.name), ownPackageId', noPackage: false, disposition: 'no',
@@ -109,10 +133,10 @@ const SITES: ReadonlyArray<Site & { readonly noPackage: boolean; readonly dispos
109133
why: 'the save door\'s collision check: the package id names a shipper in a refusal, never an envelope on a served body' },
110134
];
111135

112-
const keyOf = (site: Site) => `${site.member}: ${CALLEE}${site.args})`;
136+
const keyOf = (site: Site) => `${site.file ?? 'protocol.ts'} ${site.member}: ${CALLEE}${site.args})`;
113137

114138
describe('[#22024] every lookupArtifactItem( in protocol.ts has a recorded disposition', () => {
115-
const found = sitesOf(SOURCE);
139+
const found = sitesIn();
116140

117141
it('the sites in the source are exactly the recorded ones', () => {
118142
const recorded = SITES.map(keyOf).sort();
@@ -128,11 +152,21 @@ describe('[#22024] every lookupArtifactItem( in protocol.ts has a recorded dispo
128152

129153
it('the population is the card\'s enumeration: one definition and every call, each in a member', () => {
130154
// Non-vacuity: a scan that found nothing, or lost the members, cannot pass the test above by accident.
131-
expect(found.filter((site) => site.member === 'lookupArtifactItem' && site.args.includes(': string'))).toHaveLength(1);
155+
// [#25467 cut 15] One definition per file: the class member's signature and the seam function it delegates to.
156+
expect(found.filter((site) => site.member === 'lookupArtifactItem' && site.args.includes(': string')).map((site) => site.file ?? 'protocol.ts'))
157+
.toEqual(['protocol.ts', SEAM_FILE]);
132158
expect(found.filter((site) => site.member === '(no member)')).toEqual([]);
133159
expect(found.length).toBe(SITES.length);
134160
});
135161

162+
it('[#25467 cut 15] the seam module is scanned: the definition\'s body and both of shippedArtifactsOf\'s calls are found there', () => {
163+
// Non-vacuity for the second file: a scan that read the wrong file, or none, finds no site there.
164+
expect(found.filter((site) => site.file === SEAM_FILE).map(keyOf).sort()).toEqual(
165+
SITES.filter((site) => site.file === SEAM_FILE).map(keyOf).sort(),
166+
);
167+
expect(found.filter((site) => site.file === SEAM_FILE)).toHaveLength(3);
168+
});
169+
136170
it('a site passing fewer than three arguments is recorded as passing no package', () => {
137171
const topLevelArgs = (args: string) => {
138172
let depth = 0;

0 commit comments

Comments
 (0)