Skip to content

Commit 5a45b9b

Browse files
baozhoutaoclaude
andauthored
fix(cloud-connection): LocalManifestSource.read() says WHICH of the two things its null meant (#5426) (#5439)
`read()` answered `null` to two different questions at once — "this manifest was never installed" and "it was installed, but its ledger file cannot be read" — and its own comment (`null when absent or unreadable`) says the merge was deliberate. The consequence was not: two admin endpoints call `has()` first, so absence is already ruled out by the time they read, and both could only answer `500 MARKETPLACE_STORAGE_FAILED / "Failed to read manifest cache."` — a sentence whose only content is that the thing it just did failed, one line after `has()` said the file is there. `Unexpected end of JSON input` / `EACCES` / `EISDIR` had already been dropped in an un-bound `catch`, and nothing reached the log either. Option A of the decision point, aligned with `list()` (#5413): `read()` returns `{ entry, failure }`, `failure` being the same `SkippedManifestEntry` shape with the thrown object carried unwrapped, and present ONLY when a file exists that would not parse. `failure === undefined` with `entry === null` now means "not installed" — the fact the merged null erased. Wiring, per triage: - the ADR-0120 D5e posture gate reads `.entry` and is behaviourally UNCHANGED: a corrupt entry still counts as "no attestation on record", so the one-time ceremony is asked again rather than skipped. Conflating the two nulls is the right call at that call site; it is now made there, in the open, instead of by the ledger for everyone. - reseed and purge keep `code: 'MARKETPLACE_STORAGE_FAILED'` — the same failure, newly explained, so a client branching on the code is unaffected — and their message now names the ledger file and quotes the cause (#5390 house style), with a matching `warn` line on the server. `read()` still does not validate the parsed value's shape; `list()` still throws when the directory itself cannot be enumerated. Both deliberately unchanged. Claude-Session: https://claude.ai/code/session_016FNvXhtSdnEGEfLEsMmvxh Co-authored-by: Claude <noreply@anthropic.com>
1 parent f7df82c commit 5a45b9b

8 files changed

Lines changed: 414 additions & 40 deletions
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
---
2+
'@objectstack/cloud-connection': minor
3+
---
4+
5+
`LocalManifestSource.read()` now says WHICH of the two things its `null` meant
6+
7+
`read()` answered `null` to two different questions at once — "this manifest was
8+
never installed" and "it was installed, but its ledger file cannot be read" —
9+
and dropped the reason for the second in an un-bound `catch`. Two admin
10+
endpoints check `has()` first, so absence was already ruled out by the time they
11+
called it, and both could only answer
12+
`500 { code: 'MARKETPLACE_STORAGE_FAILED', message: 'Failed to read manifest cache.' }`:
13+
a sentence whose only content is that the thing it just did failed, one line
14+
after `has()` said the file is there. The `Unexpected end of JSON input` /
15+
`EACCES` / `EISDIR` that names the repair had already been thrown away, and
16+
nothing was written to the server log either.
17+
18+
**Breaking (`@objectstack/cloud-connection`):** `read()` returns an
19+
`InstalledManifestLookup` instead of `InstalledManifestEntry | null`.
20+
21+
- FROM: `const entry = source.read(id);`
22+
- TO: `const { entry, failure } = source.read(id);`
23+
24+
`entry` is the old return value unchanged, so a caller that legitimately treats
25+
both nulls alike migrates by reading `.entry`. `failure` is a
26+
`SkippedManifestEntry``{ file, cause }`, the same shape `list()` already
27+
reports, with the thrown object carried unwrapped — and is present ONLY when a
28+
ledger file exists and could not be read. `failure === undefined` with
29+
`entry === null` therefore means "not installed", which is the distinction the
30+
merged `null` erased. One new exported type, `InstalledManifestLookup`.
31+
32+
`read()` still does not validate the parsed value's SHAPE, and enumerating the
33+
ledger directory still throws out of `list()` — both unchanged.
34+
35+
Consumer-visible behaviour:
36+
37+
- `POST /api/v1/marketplace/install-local/:manifestId/reseed-sample-data` and
38+
`…/purge-sample-data` keep returning `500 MARKETPLACE_STORAGE_FAILED` — the
39+
same failure, so a client branching on the code is unaffected — but the
40+
message now names the ledger file to repair or remove and quotes the cause
41+
verbatim, and a matching `warn` line goes to the server log.
42+
- The install path's ADR-0120 D5e posture gate is unchanged on purpose: a
43+
corrupt entry still counts as "no attestation on record", so the one-time
44+
installation-wide-unique ceremony is asked again rather than skipped.

packages/cloud-connection/src/index.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -39,12 +39,16 @@ export type { MarketplaceInstallLocalPluginConfig } from './marketplace-install-
3939
// ADR-0007 step ⑤ — the local desired-state ledger, exported as a first-class
4040
// seam so hosts/reconcilers can read the same ledger without going through HTTP.
4141
export { LocalManifestSource, DEFAULT_INSTALLED_PACKAGES_DIR } from './local-manifest-source.js';
42-
// `list()`'s return contract is part of that seam: it reports what it could NOT
43-
// read alongside what it could (#5413), so a consumer cannot mistake half a
44-
// ledger for a whole one.
42+
// The RETURN CONTRACTS of both read paths are part of that seam. `list()`
43+
// reports what it could NOT read alongside what it could (#5413), so a consumer
44+
// cannot mistake half a ledger for a whole one; `read()` separates "never
45+
// installed" from "installed but unreadable" (#5426), so a consumer that meant
46+
// the first cannot silently answer for the second. Both carry the thrower's own
47+
// object in a `SkippedManifestEntry`.
4548
export type {
4649
InstalledManifestEntry,
4750
InstalledManifestListing,
51+
InstalledManifestLookup,
4852
SkippedManifestEntry,
4953
} from './local-manifest-source.js';
5054
export { CloudConnectionPlugin, createCloudConnectionPlugin } from './cloud-connection-plugin.js';

packages/cloud-connection/src/local-manifest-source.test.ts

Lines changed: 77 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,12 @@
33
/**
44
* LocalManifestSource — the local desired-state ledger (cloud ADR-0007 ⑤).
55
* Pure local file operations: list/read/has/write/remove, corrupt-file
6-
* tolerance AND corrupt-file REPORTING (#5413), and manifest-id sanitisation.
6+
* tolerance AND corrupt-file REPORTING on BOTH read paths — `list()` (#5413)
7+
* and `read()` (#5426) — and manifest-id sanitisation.
78
*/
89

910
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
10-
import { mkdtempSync, mkdirSync, rmSync, writeFileSync, readdirSync } from 'node:fs';
11+
import { mkdtempSync, mkdirSync, rmSync, writeFileSync, readdirSync, existsSync } from 'node:fs';
1112
import { join } from 'node:path';
1213
import { tmpdir } from 'node:os';
1314
import { LocalManifestSource, type InstalledManifestEntry } from './local-manifest-source.js';
@@ -30,19 +31,25 @@ describe('LocalManifestSource', () => {
3031
it('starts empty and lists nothing for a missing directory', () => {
3132
const src = new LocalManifestSource(join(dir, 'does-not-exist-yet'));
3233
expect(src.list()).toEqual({ entries: [], skipped: [] });
33-
expect(src.read('com.acme.crm')).toBeNull();
34+
// #5426 — absence carries NO failure. That is the whole distinction:
35+
// `{ entry: null }` means "never installed", and a consumer can tell it
36+
// apart from "installed, unreadable" without guessing.
37+
expect(src.read('com.acme.crm')).toEqual({ entry: null });
3438
expect(src.has('com.acme.crm')).toBe(false);
3539
});
3640

3741
it('write → has/read/list round-trips and upserts by manifestId', () => {
3842
const src = new LocalManifestSource(dir);
3943
src.write(entry('com.acme.crm'));
4044
expect(src.has('com.acme.crm')).toBe(true);
41-
expect(src.read('com.acme.crm')?.version).toBe('1.0.0');
45+
expect(src.read('com.acme.crm').entry?.version).toBe('1.0.0');
46+
// A clean read reports no failure either — `failure` is a finding, not
47+
// a status field that is always present.
48+
expect(src.read('com.acme.crm').failure).toBeUndefined();
4249

4350
src.write(entry('com.acme.crm', '1.1.0')); // upsert, same file
4451
expect(src.list().entries).toHaveLength(1);
45-
expect(src.read('com.acme.crm')?.version).toBe('1.1.0');
52+
expect(src.read('com.acme.crm').entry?.version).toBe('1.1.0');
4653
});
4754

4855
it('remove deletes the entry and reports absence', () => {
@@ -53,13 +60,75 @@ describe('LocalManifestSource', () => {
5360
expect(src.list()).toEqual({ entries: [], skipped: [] });
5461
});
5562

56-
it('skips corrupt ledger files in list() and nulls them in read()', () => {
63+
it('tolerates a corrupt ledger file in both read paths — one bad file costs only itself', () => {
5764
const src = new LocalManifestSource(dir);
5865
src.write(entry('com.acme.good'));
5966
writeFileSync(join(dir, 'com.acme.bad.json'), '{not json', 'utf8');
60-
// Still skipped — that half was never the bug.
67+
68+
// Tolerance was never the bug and has not changed: `list()` still hands
69+
// back the good entry, and `read()` still refuses to throw at a caller
70+
// that asked for the bad one.
6171
expect(src.list().entries.map((e) => e.manifestId)).toEqual(['com.acme.good']);
62-
expect(src.read('com.acme.bad')).toBeNull();
72+
expect(src.read('com.acme.bad').entry).toBeNull();
73+
});
74+
75+
// ── #5426 — read()'s null said WHICH of the two things it meant ─────
76+
//
77+
// ⚠️ FIXTURE NOTE: the assertion above used to be this file's whole
78+
// statement about a corrupt single read — `expect(src.read('com.acme.bad'))
79+
// .toBeNull()`, which is the exact limb this issue removed and would have
80+
// stayed green for the wrong reason (a merged null is null either way).
81+
// Tolerance keeps its assertion above; the tests below pin the fact the old
82+
// one could not see.
83+
84+
it('separates "never installed" from "installed but unreadable"', () => {
85+
const src = new LocalManifestSource(dir);
86+
// The issue's repro: a truncated ledger file for an installed package.
87+
writeFileSync(join(dir, 'com.acme.crm.json'), '{"manifestId":"com.acme.crm","manifest":{', 'utf8');
88+
89+
const absent = src.read('com.acme.never-installed');
90+
const corrupt = src.read('com.acme.crm');
91+
92+
// Both still hand back no entry — the tolerant half is unchanged.
93+
expect(absent.entry).toBeNull();
94+
expect(corrupt.entry).toBeNull();
95+
// …and they are now TELLABLE APART, which is the entire issue. Two
96+
// handlers check `has()` first, so for them only the second can happen,
97+
// and they had nothing to say about it but "Failed to read manifest
98+
// cache."
99+
expect(absent.failure).toBeUndefined();
100+
expect(corrupt.failure).toBeDefined();
101+
expect(corrupt.failure!.file).toBe('com.acme.crm.json');
102+
// The THROWN object, not a sentence this class invented.
103+
expect(corrupt.failure!.cause).toBeInstanceOf(Error);
104+
expect(String((corrupt.failure!.cause as Error).message)).toMatch(/JSON/i);
105+
});
106+
107+
it('read(): reports an UNREADABLE file, not only an unparseable one', () => {
108+
const src = new LocalManifestSource(dir);
109+
// A directory where the ledger file should be: `readFileSync` throws
110+
// EISDIR. Same swallowed null before #5426, entirely different repair —
111+
// which is why the cause travels instead of a summary.
112+
mkdirSync(join(dir, 'com.acme.crm.json'));
113+
114+
const { entry, failure } = src.read('com.acme.crm');
115+
116+
expect(entry).toBeNull();
117+
expect((failure!.cause as NodeJS.ErrnoException).code).toBe('EISDIR');
118+
});
119+
120+
it('names the SANITISED filename, so the reported path is the real one', () => {
121+
// `read()` reports the file it actually opened, not the manifest id it
122+
// was handed — a consumer joins it onto the ledger dir and tells the
123+
// operator what to repair. A hostile/odd id must not produce a path
124+
// that points at nothing.
125+
const src = new LocalManifestSource(dir);
126+
writeFileSync(join(dir, 'com_acme_crm@bad.json'.replace('@', '_')), 'nope', 'utf8');
127+
128+
const { failure } = src.read('com_acme_crm@bad');
129+
130+
expect(failure!.file).toBe('com_acme_crm_bad.json');
131+
expect(existsSync(join(dir, failure!.file))).toBe(true);
63132
});
64133

65134
// ── #5413 — a skipped file is REPORTED, not merely skipped ──────────

packages/cloud-connection/src/local-manifest-source.ts

Lines changed: 66 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,50 @@ export interface SkippedManifestEntry {
8787
cause: unknown;
8888
}
8989

90+
/**
91+
* What {@link LocalManifestSource.read} hands back for ONE manifest id — the
92+
* entry if it parsed, and the reason if it did not (#5426).
93+
*
94+
* `read()` used to answer `null` to two different questions at once: "this
95+
* manifest was never installed" and "it was installed, but its ledger file
96+
* cannot be read". The comment on it (`null when absent or unreadable`) says
97+
* the merge was deliberate; the consequence was not. Two HTTP handlers check
98+
* `has()` first — so absence is already ruled out — and then had nothing left
99+
* to say but `500 Failed to read manifest cache.`: a sentence that points at
100+
* itself, while `Unexpected end of JSON input` / `EACCES` / `EISDIR` — the one
101+
* fact that names the fix — was discarded in an un-bound `catch`.
102+
*
103+
* The two facts are now distinguishable **structurally**, not by convention:
104+
*
105+
* | On disk | `entry` | `failure` |
106+
* |---------------------------------|---------|-----------------------|
107+
* | no file for this manifest id | `null` | absent |
108+
* | a file that will not parse/read | `null` | `{ file, cause }` |
109+
* | a file that parsed | entry | absent |
110+
*
111+
* A caller that legitimately treats both nulls alike keeps doing so by reading
112+
* `.entry` — the install path's ADR-0120 D5e gate does exactly that on purpose
113+
* (a corrupt entry means "no attestation on record", so the one-time ceremony
114+
* is asked again rather than skipped: the fail-safe direction). What changed is
115+
* that conflating them is now a decision a caller makes in the open, instead of
116+
* the only thing this method let it do.
117+
*/
118+
export interface InstalledManifestLookup {
119+
/** The parsed entry, or `null` when there is none to hand back. */
120+
entry: InstalledManifestEntry | null;
121+
/**
122+
* Present ONLY when a ledger file exists for this manifest id and could not
123+
* be turned into an entry. Absent for a clean read AND for a genuine
124+
* absence — `failure === undefined` with `entry === null` means "not
125+
* installed", which is the distinction the old `null` erased.
126+
*
127+
* Shares {@link SkippedManifestEntry} with `list()` deliberately: it is the
128+
* same fact about the same file, and a consumer that reports both (`os
129+
* doctor`-style) should not need two shapes to say one thing.
130+
*/
131+
failure?: SkippedManifestEntry;
132+
}
133+
90134
/**
91135
* What {@link LocalManifestSource.list} hands back — what it READ, and what it
92136
* could NOT (#5413).
@@ -164,14 +208,29 @@ export class LocalManifestSource {
164208
return { entries, skipped };
165209
}
166210

167-
/** Read one entry; null when absent or unreadable. */
168-
read(manifestId: string): InstalledManifestEntry | null {
169-
const file = this.fileFor(manifestId);
170-
if (!existsSync(file)) return null;
211+
/**
212+
* Read one entry — and, when there is none, say WHICH of the two reasons
213+
* applies (#5426).
214+
*
215+
* See {@link InstalledManifestLookup} for the table. In short: absent →
216+
* `{ entry: null }`; unreadable → `{ entry: null, failure: { file, cause } }`
217+
* with the thrown object carried as-is, never re-wrapped and never
218+
* stringified into a sentence this class invented.
219+
*
220+
* ⚠️ Deliberately NOT total in the other direction: this method does not
221+
* validate the parsed value's SHAPE. A file holding well-formed JSON that
222+
* is not an installed-package entry parses, and is handed back — same as
223+
* before. Schema-checking the ledger is a separate contract decision
224+
* (which schema, what a rejection means at boot) and is not made here.
225+
*/
226+
read(manifestId: string): InstalledManifestLookup {
227+
const name = safeFilename(manifestId);
228+
const file = join(this.dir, name);
229+
if (!existsSync(file)) return { entry: null };
171230
try {
172-
return JSON.parse(readFileSync(file, 'utf8'));
173-
} catch {
174-
return null;
231+
return { entry: JSON.parse(readFileSync(file, 'utf8')) };
232+
} catch (cause) {
233+
return { entry: null, failure: { file: name, cause } };
175234
}
176235
}
177236

0 commit comments

Comments
 (0)