Skip to content

Commit efc5447

Browse files
claude[bot]claude
andauthored
fix(metadata): RemoteLoader.list() no longer reports a nameless body as a literal undefined (#16107)
`list()` declares `Promise<string[]>` and read the collection as `loadMany<{ name: string }>(type)` before `items.map(i => i.name)`. That type argument is an assertion about bodies that arrived over HTTP; nothing checked it. A body with no top-level `name` yielded `undefined`, pushed into an array the signature declares as `string[]`, and `MetadataManager.listNames()` unions loader `list()` output unfiltered — so the violation reached consumers. On this fixture `listNames()` answered `[ 'account', undefined, 42 ]`. The guard is `DatabaseLoader.list()`'s, one file away: same cast-then-map spelling, `typeof name === 'string'` behind it. `RemoteLoader` was the only one of the four loaders in the directory with no guard at all. The predicate is spelled as a type guard with the mapped element left `unknown`, so `tsc` proves the declared `string[]` instead of a cast asserting it. Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ Co-authored-by: Claude <noreply@anthropic.com>
1 parent 0ef4f80 commit efc5447

3 files changed

Lines changed: 292 additions & 2 deletions

File tree

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
"@objectstack/metadata": patch
3+
---
4+
5+
`RemoteLoader.list()` no longer reports a nameless remote body as a literal `undefined`.
6+
7+
The method declares `Promise<string[]>` and read the collection as `loadMany<{ name: string }>(type)` before mapping `items.map(i => i.name)`. That type argument is an **assertion** about bodies that arrived over HTTP, and nothing checked it: a body with no top-level `name` yielded `undefined`, which went into an array the signature declares as `string[]`. `MetadataManager.listNames()` unions loader `list()` output unfiltered, so the violation reached consumers — measured on this fixture, `listNames()` answered `[ 'account', undefined, 42 ]`.
8+
9+
The guard is `DatabaseLoader.list()`'s, one file away: the same cast-then-map spelling with `.filter(name => typeof name === 'string')` behind it. `RemoteLoader` was the only one of the four loaders in that directory with no guard at all — `MemoryLoader` answers with its store keys, and `FilesystemLoader` reports only names `findFile()` resolves. Dropping silently rather than throwing is the direction those siblings already carry: a name in the list that the door answers `null` for is the silent failure an author reads as their own typo, so the list is narrowed to agree with the door.
10+
11+
Nothing that was validly returned before stops being returned: the only entries that disappear are the ones whose type the signature already ruled out. A caller that previously received `[undefined]` now receives `[]`. `loadMany()` is deliberately untouched — it keys nothing, so a body carrying no `name` is still served there; this loader reads over HTTP and holds no store key, so `body.name` is the only identity it has and the family's "identity is the store key" rule cannot be satisfied for it.
Lines changed: 243 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,243 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* #15037 — `RemoteLoader.list()` declares `Promise<string[]>` and maps a
5+
* nameless body straight through, so `listNames()` can hand a caller a literal
6+
* `undefined` where the type says a `string`.
7+
*
8+
* ---------------------------------------------------------------------------
9+
* The defect (measured on `origin/main` @ 9c3fda5fb, this fixture)
10+
* ---------------------------------------------------------------------------
11+
* async list(type: string): Promise<string[]> {
12+
* const items = await this.loadMany<{ name: string }>(type);
13+
* return items.map(i => i.name);
14+
* }
15+
*
16+
* The type argument `{ name: string }` is an ASSERTION about bodies that
17+
* arrived over HTTP; nothing checks it. A body with no top-level `name` yields
18+
* `i.name === undefined`, and that `undefined` is pushed into an array the
19+
* signature declares as `string[]` — a runtime violation of a declared type,
20+
* not merely an untidy entry. `MetadataManager.listNames()` unions loader
21+
* `list()` output unfiltered (`result.forEach(item => names.add(item))`), so
22+
* the violation reaches consumers, which then use the value as an object key,
23+
* lower-case it, or feed it back to a by-name `load()`.
24+
*
25+
* ---------------------------------------------------------------------------
26+
* The direction, and why it was not this seat's to choose
27+
* ---------------------------------------------------------------------------
28+
* Three of the four sibling loaders in this directory had already answered it,
29+
* and `RemoteLoader` was the only one with no guard at all:
30+
*
31+
* DatabaseLoader `rows.map(row => row.name as string)`
32+
* `.filter(name => typeof name === 'string')` — guarded
33+
* MemoryLoader `Array.from(typeStore.keys())` — store keys
34+
* FilesystemLoader narrowed by #14486 to names `findFile()` resolves
35+
* RemoteLoader `items.map(i => i.name)` — unguarded
36+
*
37+
* So the repair is `DatabaseLoader`'s guard, one file away: same directory,
38+
* same method name, same "cast then map" spelling, one `.filter()` behind it.
39+
* "Refuse loudly" was NOT taken, and that is a landed decision rather than a
40+
* preference — `DatabaseLoader`'s guard is a silent `.filter()`, and
41+
* `FilesystemLoader`'s narrowing carries a maintainer ruling (via the director
42+
* seat on #14486, 2026-09-02, direction A, with B explicitly refused) whose
43+
* own reasoning is this card's:
44+
*
45+
* 「A name in the list that `get()` answers `null` for is the silent failure
46+
* an author reads as their own typo, so they retry the same word: the list
47+
* and the door now agree instead.」
48+
*
49+
* An `undefined` in `listNames()` is the extreme form of a name the door can
50+
* never answer.
51+
*
52+
* ⛔ What is deliberately NOT copied: `MemoryLoader`'s structural fix (return
53+
* the store key) and #14205's keying rule ("identity is the key the store
54+
* holds an item under, not `body.name`"). This loader reads over HTTP and has
55+
* no store key to fall back on, so `body.name` is all it has. The guard SHAPE
56+
* is what transfers; the family's keying rule cannot be satisfied here.
57+
*
58+
* ---------------------------------------------------------------------------
59+
* Why the double is a `fetch`, not a hand-written loader
60+
* ---------------------------------------------------------------------------
61+
* Every case below drives the REAL `RemoteLoader` and, on the manager face, a
62+
* REAL `MetadataManager`. The only thing stubbed is the wire — a `fetch` that
63+
* serves the collection and answers the by-name door — so the code under test
64+
* is this loader's own `list()`/`loadMany()`/`load()`, not a verdict handed in
65+
* by a mock.
66+
*
67+
* `CONTROL:` cases pin what must NOT move: the well-formed body stays listed
68+
* and stays loadable, so a guard that dropped everything would fail here too.
69+
* `RECORD:` pins behaviour this repair deliberately leaves alone.
70+
*/
71+
72+
import { describe, it, expect, vi, afterEach } from 'vitest';
73+
import { MetadataManager } from '../metadata-manager.js';
74+
import { RemoteLoader } from './remote-loader.js';
75+
76+
const BASE = 'https://metadata.invalid/api';
77+
const TYPE = 'object';
78+
79+
/** One well-formed body, and the two shapes that violate the asserted cast. */
80+
const NAMED = { name: 'account', label: 'Account' };
81+
const NAMELESS = { label: 'Nameless' };
82+
const NUMERIC_NAME = { name: 42, label: 'Numeric' };
83+
84+
const BODIES: Array<Record<string, unknown>> = [NAMED, NAMELESS, NUMERIC_NAME];
85+
86+
/** Exactly the bodies whose top-level `name` is a string. */
87+
const LISTABLE = ['account'];
88+
89+
/**
90+
* The remote, minimally: `GET /{type}` serves the collection, `GET|HEAD
91+
* /{type}/{name}` is the by-name door and answers only for a body whose
92+
* top-level `name` equals that segment. That door is what makes
93+
* `listNames()`/`get()` comparable — it is the remote analogue of
94+
* `findFile()`.
95+
*/
96+
function serveRemote(bodies: Array<Record<string, unknown>>) {
97+
const collection = `${BASE}/${TYPE}`;
98+
99+
return vi.fn(async (input: unknown, init?: { method?: string }) => {
100+
const url = String(input);
101+
const method = (init?.method ?? 'GET').toUpperCase();
102+
const json = (body: unknown) =>
103+
new Response(JSON.stringify(body), {
104+
status: 200,
105+
headers: { 'content-type': 'application/json' },
106+
});
107+
108+
if (url === collection) {
109+
return method === 'HEAD' ? new Response(null, { status: 200 }) : json(bodies);
110+
}
111+
112+
if (url.startsWith(`${collection}/`)) {
113+
const segment = url.slice(collection.length + 1);
114+
const hit = bodies.find(body => body.name === segment);
115+
if (!hit) return new Response(null, { status: 404 });
116+
return method === 'HEAD' ? new Response(null, { status: 200 }) : json(hit);
117+
}
118+
119+
return new Response(null, { status: 404 });
120+
});
121+
}
122+
123+
function loader(bodies: Array<Record<string, unknown>> = BODIES): RemoteLoader {
124+
vi.stubGlobal('fetch', serveRemote(bodies));
125+
return new RemoteLoader(BASE);
126+
}
127+
128+
/** A cold manager — empty registry, one remote loader answering. */
129+
function coldManager(bodies: Array<Record<string, unknown>> = BODIES): MetadataManager {
130+
const manager = new MetadataManager({ formats: ['json'], loaders: [] });
131+
manager.registerLoader(loader(bodies));
132+
return manager;
133+
}
134+
135+
afterEach(() => {
136+
vi.unstubAllGlobals();
137+
});
138+
139+
describe('#15037 RemoteLoader.list() keeps the `string[]` its signature declares', () => {
140+
it('EVERY listed name is a string — the declared-type violation itself', async () => {
141+
// The claim the signature makes, asserted directly. Before the guard this
142+
// answered `['account', undefined, 42]` against a `Promise<string[]>`.
143+
for (const name of await loader().list(TYPE)) {
144+
expect(typeof name).toBe('string');
145+
}
146+
});
147+
148+
it('a body with no top-level `name` is no longer listed as `undefined`', async () => {
149+
const listed = await loader().list(TYPE);
150+
151+
expect(listed).not.toContain(undefined);
152+
expect(listed.sort()).toEqual(LISTABLE);
153+
});
154+
155+
it('a non-string `name` is dropped too — the guard is `typeof`, not truthiness', async () => {
156+
// `DatabaseLoader`'s predicate is `typeof name === 'string'`, so it rejects
157+
// every shape the cast lied about, not just the missing one. A body whose
158+
// `name` is a number violates `Promise<string[]>` exactly as hard.
159+
expect(await loader().list(TYPE)).not.toContain(42 as unknown as string);
160+
});
161+
162+
it('EVERY listed name resolves through exists(), stat() and load()', async () => {
163+
// The list-and-door agreement, on this loader's own face.
164+
const remote = loader();
165+
166+
for (const name of await remote.list(TYPE)) {
167+
expect(await remote.exists(TYPE, name)).toBe(true);
168+
expect(await remote.stat(TYPE, name)).not.toBeNull();
169+
expect((await remote.load(TYPE, name)).data).not.toBeNull();
170+
}
171+
});
172+
173+
it('CONTROL: the well-formed body is still listed and still loadable', async () => {
174+
// A guard that dropped everything would satisfy every case above. This is
175+
// what stops that: the named body must survive untouched.
176+
const remote = loader();
177+
178+
expect(await remote.list(TYPE)).toContain('account');
179+
expect((await remote.load(TYPE, 'account')).data).toEqual(NAMED);
180+
});
181+
182+
it('CONTROL: a collection of only well-formed bodies is unchanged by the guard', async () => {
183+
expect((await loader([NAMED, { name: 'contact' }]).list(TYPE)).sort()).toEqual([
184+
'account',
185+
'contact',
186+
]);
187+
});
188+
189+
it('CONTROL: an empty collection still lists nothing', async () => {
190+
expect(await loader([]).list(TYPE)).toEqual([]);
191+
});
192+
});
193+
194+
describe('#15037 the repair reaches MetadataManager', () => {
195+
it('listNames() and get() give the same answer for every name', async () => {
196+
// The #14486 shape, reused on the `RemoteLoader` face as triage asked.
197+
// Before the guard, `listNames()` carried `undefined`, and `get()` for it
198+
// fetched `.../object/undefined` and answered `undefined` — the list and
199+
// the door disagreeing, which is the failure an author reads as their own
200+
// typo.
201+
const manager = coldManager();
202+
203+
for (const name of await manager.listNames(TYPE)) {
204+
expect(await manager.get(TYPE, name)).toBeDefined();
205+
}
206+
});
207+
208+
it('listNames() no longer forwards a literal `undefined` to consumers', async () => {
209+
const names = await coldManager().listNames(TYPE);
210+
211+
expect(names).not.toContain(undefined);
212+
expect(names.every(name => typeof name === 'string')).toBe(true);
213+
expect(names.sort()).toEqual(LISTABLE);
214+
});
215+
216+
it('CONTROL: listNames() still reports the well-formed body, and get() resolves it', async () => {
217+
const manager = coldManager();
218+
219+
expect(await manager.listNames(TYPE)).toContain('account');
220+
expect(await manager.get(TYPE, 'account')).toEqual(NAMED);
221+
});
222+
});
223+
224+
describe('#15037 RECORD: what this repair deliberately leaves alone', () => {
225+
it('RECORD: loadMany() still returns the bodies list() no longer names', async () => {
226+
// The guard narrows `list()` only. Filtering the body read would change
227+
// what `MetadataManager.loadMany()` aggregates — a different direction
228+
// (#14341/#14205 fixed items going MISSING; this card is about one
229+
// APPEARING as `undefined`) and not this card's. `loadMany` keys nothing,
230+
// so a nameless body is still legitimately served there.
231+
const bodies = await loader().loadMany<Record<string, unknown>>(TYPE);
232+
233+
expect(bodies).toHaveLength(3);
234+
expect(bodies).toContainEqual(NAMELESS);
235+
});
236+
237+
it('RECORD: the by-name door cannot reach a nameless body, before or after', async () => {
238+
// `RemoteLoader` reads over HTTP and holds no store key, so there is no
239+
// identity to list a nameless body under. #14205's keying rule cannot be
240+
// satisfied here; the list is narrowed to agree with the door instead.
241+
expect((await loader().load(TYPE, 'Nameless')).data).toBeNull();
242+
});
243+
});

packages/metadata/src/loaders/remote-loader.ts

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -110,9 +110,45 @@ export class RemoteLoader implements MetadataLoader {
110110
};
111111
}
112112

113+
/**
114+
* [#15037] Report only the names that ARE names.
115+
*
116+
* This read used to be `loadMany<{ name: string }>(type)` mapped straight to
117+
* `items.map(i => i.name)`. That type argument is an ASSERTION about bodies
118+
* that arrived over HTTP, and nothing checked it: a body with no top-level
119+
* `name` yielded `undefined`, which went into an array this signature
120+
* declares as `string[]` and reached consumers through
121+
* `MetadataManager.listNames()` — a runtime violation of a declared type,
122+
* not an untidy entry. A consumer that keys by it, lower-cases it, or feeds
123+
* it back to a by-name `load()` gets `undefined` where the type says it
124+
* cannot be.
125+
*
126+
* The guard is `DatabaseLoader.list()`'s, one file away: same cast-then-map
127+
* spelling, one `typeof` filter behind it. Silently dropping is the landed
128+
* direction, not a preference — `DatabaseLoader` drops rather than throws,
129+
* and `FilesystemLoader`'s narrowing carries a maintainer ruling (via the
130+
* director seat on #14486, 2026-09-02) that chose narrowing (A) over
131+
* refusing loudly (B), because a name in the list that the door answers
132+
* `null` for is the silent failure an author reads as their own typo. An
133+
* `undefined` here is the extreme form of that name.
134+
*
135+
* ⛔ NOT copied from the siblings: `MemoryLoader` answers with its store
136+
* keys, and #14205 ruled that identity is the key the store holds an item
137+
* under rather than `body.name`. This loader reads over HTTP and holds no
138+
* store key, so `body.name` is the only identity it has — the list is
139+
* narrowed to agree with the door instead. `loadMany()` is deliberately
140+
* untouched: it keys nothing, so a nameless body is still served there.
141+
*
142+
* The predicate is spelled as a type guard, and the mapped element type left
143+
* `unknown`, so `tsc` PROVES the declared `string[]` instead of a cast
144+
* asserting it — otherwise the compiler reads the filter as always-true and
145+
* a later reader deletes it as dead.
146+
*/
113147
async list(type: string): Promise<string[]> {
114-
const items = await this.loadMany<{ name: string }>(type);
115-
return items.map(i => i.name);
148+
const items = await this.loadMany<{ name?: unknown }>(type);
149+
return items
150+
.map(item => item.name)
151+
.filter((name): name is string => typeof name === 'string');
116152
}
117153

118154
async save(

0 commit comments

Comments
 (0)