Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
158 changes: 158 additions & 0 deletions packages/metadata-protocol/src/protocol-artifact-lookup.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [#25467 cut 15] The artifact registry's lookups of
* `ObjectStackProtocolImplementation` (ADR-0010 metadata protection): the
* artifact an installed code package registered for an item
* (`lookupArtifactItem`), every package's artifact for it
* (`shippedArtifactsOf`), the packages that can ship an item of a type
* (`shippingPackagesOf`), and the item-lock resolution's artifact layer over
* them (`artifactLockLayerAt`), moved out of `protocol.ts` whole.
*
* A MOVE, not a redesign. Each function below is the body of the
* `ObjectStackProtocolImplementation` method of the same name as it stood,
* re-indented one level and otherwise unedited: the same registry reads, in
* the same order, answering the same artifacts.
*
* ## How the moved bodies reach the class
*
* The shape #23050 set for `rest-server.ts` (its `data-action-routes.ts`
* header carries the full reasoning), as `protocol-batch-data.ts` applies it.
* The class keeps every method — the published `dist/*.d.ts` lists each one's
* name, docblock and declared signature — and each body is one literal of
* exactly the {@link ArtifactLookupHost} members that method reads, plus a
* `.call`. The engine is handed over as a getter, so the body reads the
* protocol's engine at call time. A method is handed over as a closure that
* calls it on the protocol at call time, so a test double or spy set on the
* instance is still the one the body reaches. That holds between the four
* functions here too: `shippedArtifactsOf` reaches the lookup and the
* shipping-package read through the class methods, not by calling the
* functions below directly. Every other reader of these four methods keeps
* calling the class method.
*
* #22024's call-site pin (`protocol.lookup-artifact-item-call-sites.test.ts`)
* scans this module beside `protocol.ts`: the lookup's definition and
* `shippedArtifactsOf`'s two calls of it live here now.
*
* What the bodies read at module level they import from where `protocol.ts`
* imported it; `ShippingPackages` is `protocol.ts`'s own exported type.
* Nothing re-exports this module, so the published surface does not move.
*/

import { PLURAL_TO_SINGULAR } from '@objectstack/spec/shared';
import { resolveArtifactLockLayer, type ItemAddress } from './item-lock.js';
import type { ObjectStackProtocolImplementation, ShippingPackages } from './protocol.js';

/**
* The `ObjectStackProtocolImplementation` members the functions below read —
* every one of them, and no other; each function's `this` is the `Pick` of the
* members IT reads. Each is typed AS the class member (an indexed access on
* the class), so a change to a member's signature on the class is a change
* here too, and naming a member the class does not have is a compile error.
*/
export interface ArtifactLookupHost {
readonly engine: ObjectStackProtocolImplementation['engine'];
lookupArtifactItem: ObjectStackProtocolImplementation['lookupArtifactItem'];
shippingPackagesOf: ObjectStackProtocolImplementation['shippingPackagesOf'];
shippedArtifactsOf: ObjectStackProtocolImplementation['shippedArtifactsOf'];
}

/** The body of `ObjectStackProtocolImplementation#lookupArtifactItem`; the method's docblock there is its contract. */
export function lookupArtifactItem(
this: Pick<ArtifactLookupHost, 'engine'>,
type: string, name: string, currentPackageId?: string, member?: string,
): unknown {
const registry = (this.engine as any)?.registry;
if (!registry) return undefined;
const singular = PLURAL_TO_SINGULAR[type] ?? type;
// Prefer the artifact-only lookup: it scans composite
// (`<packageId>:<name>`) entries first, so an overlay row hydrated
// into the plain key (getMetaItems / loadMetaFromDb) can never
// shadow the packaged artifact's protection envelope (ADR-0010
// §3.3 — pre-fix, that shadow made a `_lock: full` app read back
// as unlocked after PUT+GET until restart). `currentPackageId`
// (ADR-0048) makes that scan package-scoped (prefer-local).
if (typeof registry.getArtifactItem === 'function') {
return registry.getArtifactItem(singular, name, currentPackageId, member)
?? registry.getArtifactItem(type, name, currentPackageId, member);
}
// Partial registry mocks in tests — fall back to getItem and apply
// the same package-provenance filter inline.
if (typeof registry.getItem !== 'function') return undefined;
const item = registry.getItem(singular, name, currentPackageId) ?? registry.getItem(type, name, currentPackageId);
if (!item || !(item as any)._packageId || (item as any)._packageId === 'sys_metadata') {
return undefined;
}
return item;
}

/** The body of `ObjectStackProtocolImplementation#artifactLockLayerAt`; the method's docblock there is its contract. */
export function artifactLockLayerAt(
this: Pick<ArtifactLookupHost, 'shippedArtifactsOf'>,
address: ItemAddress, shipping?: ShippingPackages,
): readonly unknown[] {
return resolveArtifactLockLayer(address, () => this.shippedArtifactsOf(address.type, address.name, shipping));
}

/** The body of `ObjectStackProtocolImplementation#shippedArtifactsOf`; the method's docblock there is its contract. */
export function shippedArtifactsOf(
this: Pick<ArtifactLookupHost, 'lookupArtifactItem' | 'shippingPackagesOf'>,
type: string, name: string, shipping?: ShippingPackages,
): unknown[] {
const shipped: unknown[] = [];
const add = (artifact: unknown): void => {
if (artifact !== undefined && artifact !== null && !shipped.includes(artifact)) shipped.push(artifact);
};
add(this.lookupArtifactItem(type, name));
if ((PLURAL_TO_SINGULAR[type] ?? type) === 'object') return shipped;
const packages = shipping ?? this.shippingPackagesOf(type);
for (const packageId of new Set([...(packages.byName.get(name) ?? []), ...packages.anyName])) {
const own = this.lookupArtifactItem(type, name, packageId) as { _packageId?: unknown } | undefined;
if (own?._packageId === packageId) add(own);
}
return shipped;
}

/** The body of `ObjectStackProtocolImplementation#shippingPackagesOf`; the method's docblock there is its contract. */
export function shippingPackagesOf(
this: Pick<ArtifactLookupHost, 'engine'>,
type: string,
): ShippingPackages {
const registry = (this.engine as any)?.registry;
const byName = new Map<string, Set<string>>();
const anyName = new Set<string>();
if (!registry) return { byName, anyName };
const isPackage = (id: unknown): id is string => typeof id === 'string' && id !== '' && id !== 'sys_metadata';
const listing = (read: () => unknown): readonly unknown[] => {
try {
const listed = read();
return Array.isArray(listed) ? listed : [];
} catch {
return []; // See this method's header: the package-less lookup still answers.
}
};
if (typeof registry.listItems === 'function') {
for (const spelling of new Set([PLURAL_TO_SINGULAR[type] ?? type, type])) {
for (const entry of listing(() => registry.listItems(spelling))) {
const { _packageId: id, name } = (entry ?? {}) as { _packageId?: unknown; name?: unknown };
if (!isPackage(id)) continue;
if (typeof name !== 'string') {
anyName.add(id);
continue;
}
const ids = byName.get(name);
if (ids) ids.add(id);
else byName.set(name, new Set([id]));
}
}
}
if (typeof registry.getAllPackages === 'function') {
const canTell = typeof registry.isPackageDisabled === 'function';
for (const record of listing(() => registry.getAllPackages())) {
const r = record as { manifest?: { id?: unknown }; id?: unknown } | null | undefined;
const id = r?.manifest?.id ?? r?.id;
if (isPackage(id) && (!canTell || registry.isPackageDisabled(id))) anyName.add(id);
}
}
return { byName, anyName };
}
Original file line number Diff line number Diff line change
Expand Up @@ -22,26 +22,46 @@
*
* The population is the card's own enumeration command,
* `git grep -n 'lookupArtifactItem(' -- packages/metadata-protocol/src`
* (tests excluded), which is one file: every match is a call or the
* definition. Each is keyed by the class member it sits in and its argument
* text, which survive line moves and fail on any change of scope. A new call,
* a removed one or a re-scoped one turns this red until its row is recorded
* here with its disposition (and the card's table in the PR that adds it).
* (tests excluded), which is two files: every match is a call or a
* definition. Each is keyed by the file, the class member or module function
* it sits in, and its argument text, which survive line moves and fail on any
* change of scope. A new call, a removed one or a re-scoped one turns this red
* until its row is recorded here with its disposition (and the card's table in
* the PR that adds it).
*
* [#25467 cut 15] The second file is `protocol-artifact-lookup.ts`. The
* lookup's body moved there with `shippedArtifactsOf`'s, so it holds the
* function the class member delegates to and that member's two calls; the
* class member keeps its signature in `protocol.ts`, and `shippedArtifactsOf`'s
* stub there hands the moved body the lookup as a closure, one more site.
*/
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { describe, expect, it } from 'vitest';

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

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

interface Site { readonly file?: string; readonly member: string; readonly args: string }

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

/** Every `lookupArtifactItem(` in the source: the member it sits in and its argument text, whitespace-normalised. */
function sitesOf(source: string): Site[] {
/** Every `lookupArtifactItem(` in one source: the member it sits in (by `header`) and its argument text, whitespace-normalised. */
function sitesOf(source: string, header: RegExp): Site[] {
const lines = source.split('\n');
const sites: Site[] = [];
for (let at = source.indexOf(CALLEE); at !== -1; at = source.indexOf(CALLEE, at + 1)) {
Expand All @@ -53,8 +73,8 @@ function sitesOf(source: string): Site[] {
const args = source.slice(at + CALLEE.length, end - 1).replace(/\s+/g, ' ').trim().replace(/,$/, '');
let member = '(no member)';
for (let line = source.slice(0, at).split('\n').length - 1; line >= 0; line--) {
const header = MEMBER_HEADER.exec(lines[line]);
if (header) { member = header[1]; break; }
const found = header.exec(lines[line]);
if (found) { member = found[1]; break; }
}
sites.push({ member, args });
}
Expand All @@ -70,7 +90,9 @@ type Disposition = 'definition' | 'served-package envelope' | 'no';
*/
const SITES: ReadonlyArray<Site & { readonly noPackage: boolean; readonly disposition: Disposition; readonly why: string }> = [
{ member: 'lookupArtifactItem', args: 'type: string, name: string, currentPackageId?: string, member?: string', noPackage: false, disposition: 'definition',
why: 'the definition itself' },
why: 'the definition itself: the class member\'s declared signature (its body delegates to the seam function below)' },
{ file: SEAM_FILE, member: 'lookupArtifactItem', args: 'this: Pick<ArtifactLookupHost, \'engine\'>, type: string, name: string, currentPackageId?: string, member?: string', noPackage: false, disposition: 'definition',
why: '[#25467 cut 15] the definition\'s body, the function the class member delegates to' },
{ member: 'readMetaItem', args: 'request.type, request.name, envelopePackageId(request.packageId, item), bundleMemberOf(request.type, item)', noPackage: true, disposition: 'served-package envelope',
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' },
{ member: 'readFlattenedMetaItems', args: 'request.type, itemName, itemPackageId, bundleMemberOf(request.type, it)', noPackage: true, disposition: 'served-package envelope',
Expand All @@ -91,10 +113,12 @@ const SITES: ReadonlyArray<Site & { readonly noPackage: boolean; readonly dispos
why: 'a boolean containment test over the one owner of an object' },
{ member: 'packagedArtifactOwner', args: 'folded.type, folded.name', noPackage: true, disposition: 'no',
why: 'a shipped-package check by name: it returns a package id, never an envelope on a served body' },
{ member: 'shippedArtifactsOf', args: 'type, name', noPackage: true, disposition: 'no',
{ file: SEAM_FILE, member: 'shippedArtifactsOf', args: 'type, name', noPackage: true, disposition: 'no',
why: 'the lock\'s artifact layer (the one item-lock resolution), never an envelope on a served body' },
{ member: 'shippedArtifactsOf', args: 'type, name, packageId', noPackage: false, disposition: 'no',
{ file: SEAM_FILE, member: 'shippedArtifactsOf', args: 'type, name, packageId', noPackage: false, disposition: 'no',
why: 'scoped to a named package, and kept only when it is that package\'s own' },
{ member: 'shippedArtifactsOf', args: '...a', noPackage: true, disposition: 'no',
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' },
{ member: 'hydrateOverlayIntoRegistry', args: 'type, (data as any).name, options.packageId ?? undefined, bundleMemberOf(type, data)', noPackage: true, disposition: 'no',
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' },
{ member: 'expandRuntimeViewContainer', args: 'type, String(item.name), ownPackageId', noPackage: false, disposition: 'no',
Expand All @@ -109,10 +133,10 @@ const SITES: ReadonlyArray<Site & { readonly noPackage: boolean; readonly dispos
why: 'the save door\'s collision check: the package id names a shipper in a refusal, never an envelope on a served body' },
];

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

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

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

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

it('[#25467 cut 15] the seam module is scanned: the definition\'s body and both of shippedArtifactsOf\'s calls are found there', () => {
// Non-vacuity for the second file: a scan that read the wrong file, or none, finds no site there.
expect(found.filter((site) => site.file === SEAM_FILE).map(keyOf).sort()).toEqual(
SITES.filter((site) => site.file === SEAM_FILE).map(keyOf).sort(),
);
expect(found.filter((site) => site.file === SEAM_FILE)).toHaveLength(3);
});

it('a site passing fewer than three arguments is recorded as passing no package', () => {
const topLevelArgs = (args: string) => {
let depth = 0;
Expand Down
Loading
Loading