Skip to content

Commit 8065022

Browse files
authored
fix(#19): vault all skips broken cache entries instead of aborting (#25)
* fix(#19): vault all skips broken cache entries instead of aborting exportAllPapersToVault used Effect.forEach in fail-fast mode, so the first cache entry missing paper.md aborted the whole batch — every valid entry after it was silently dropped. Building any non-trivial vault (10+ papers) reliably hit one stale entry and forced the user to script around the command per-paper. Each entry now runs through an isolated catch and produces either an ok or failed attempt record. The command prints a summary Exported N of M papers to <vault> Skipped: <id> — <reason> and exits 0 as long as at least one paper landed. Only when every entry failed (and the cache wasn't empty to begin with) does the command fail with a new VaultExportAllFailed error and exit 1. Closes #19. * fix(#19): narrow vault all skip boundary to typed cache errors Replace the broad Effect.catch in exportAllPapersToVault with Effect.catchTags({ VaultCacheMissing, VaultCacheMalformed }). Only per-entry cache failures (missing paper.md, unsafe target path, unparseable cached id) are converted to skipped entries; VaultFsError, VaultInvalidPath, VaultConfigMissing now keep failing the whole command instead of being reported as a skipped paper. ExportAttempt and VaultExportFailure hold the typed error object until the CLI renderer turns it into a display line. The visible 'Skipped: <id> — <message>' format is unchanged. Also drop the redundant Effect.fail(new VaultExportAllFailed(...)) in favour of bare 'yield* new VaultExportAllFailed(...)' (Data.TaggedError instances are themselves Effects), matching the existing codebase convention.
1 parent ee54f7b commit 8065022

4 files changed

Lines changed: 124 additions & 13 deletions

File tree

src/cli.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -704,6 +704,8 @@ const formatVaultError = (error: VaultError): string => {
704704
return `error: vault export failed: ${error.message}`
705705
case "VaultFsError":
706706
return `error: vault export failed: ${error.message}`
707+
case "VaultExportAllFailed":
708+
return `error: vault export failed: ${error.message}`
707709
}
708710
}
709711

src/commands/vault.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { Console, Effect } from "effect"
22
import { CachePaths } from "../cache.js"
3-
import { exportAllPapersToVault, exportPaperToVault, initVault, type VaultError, type VaultInitResult, type VaultExportResult, type VaultExportAllResult, VaultPaths } from "../vault.js"
3+
import { exportAllPapersToVault, exportPaperToVault, initVault, type VaultError, VaultExportAllFailed, type VaultInitResult, type VaultExportResult, type VaultExportAllResult, VaultPaths } from "../vault.js"
44
import type { CliCommand } from "../parser.js"
55

66
export const runVaultCommand = (
@@ -21,6 +21,9 @@ export const runVaultCommand = (
2121
case "vault-all": {
2222
const result = yield* exportAllPapersToVault()
2323
yield* Console.log(renderVaultExportAll(result))
24+
if (result.exported.length === 0 && result.total > 0) {
25+
return yield* new VaultExportAllFailed({ message: "all cache entries failed to export" })
26+
}
2427
return
2528
}
2629
}
@@ -30,6 +33,14 @@ const renderVaultInit = (result: VaultInitResult): string => `Configured vault:
3033

3134
const renderVaultExport = (result: VaultExportResult): string => `Exported ${result.id} to ${result.path}`
3235

33-
const renderVaultExportAll = (result: VaultExportAllResult): string => `Exported ${result.count} papers to ${result.path}`
36+
const renderVaultExportAll = (result: VaultExportAllResult): string => {
37+
const header = `Exported ${result.exported.length} of ${result.total} papers to ${result.path}`
38+
if (result.failed.length === 0) return header
39+
return [
40+
header,
41+
"Skipped:",
42+
...result.failed.map((entry) => ` ${entry.id}${entry.error.message}`),
43+
].join("\n")
44+
}
3445

3546

src/vault.ts

Lines changed: 41 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -36,12 +36,17 @@ export class VaultFsError extends Data.TaggedError("VaultFsError")<{
3636
readonly cause: unknown
3737
}> {}
3838

39+
export class VaultExportAllFailed extends Data.TaggedError("VaultExportAllFailed")<{
40+
readonly message: string
41+
}> {}
42+
3943
export type VaultError =
4044
| VaultConfigMissing
4145
| VaultInvalidPath
4246
| VaultCacheMissing
4347
| VaultCacheMalformed
4448
| VaultFsError
49+
| VaultExportAllFailed
4550

4651
export type VaultInitResult = {
4752
readonly path: string
@@ -52,8 +57,15 @@ export type VaultExportResult = {
5257
readonly path: string
5358
}
5459

60+
export type VaultExportFailure = {
61+
readonly id: string
62+
readonly error: VaultCacheMissing | VaultCacheMalformed
63+
}
64+
5565
export type VaultExportAllResult = {
56-
readonly count: number
66+
readonly exported: ReadonlyArray<VaultExportResult>
67+
readonly failed: ReadonlyArray<VaultExportFailure>
68+
readonly total: number
5769
readonly path: string
5870
}
5971

@@ -81,25 +93,49 @@ export const exportPaperToVault = (id: PaperIdentifier): Effect.Effect<VaultExpo
8193
Effect.flatMap((vaultPath) => exportOne(vaultPath, id))
8294
)
8395

96+
type ExportAttempt =
97+
| { readonly _tag: "Exported"; readonly result: VaultExportResult }
98+
| { readonly _tag: "Skipped"; readonly id: string; readonly error: VaultCacheMissing | VaultCacheMalformed }
99+
84100
export const exportAllPapersToVault = (): Effect.Effect<VaultExportAllResult, VaultError, VaultPaths | CachePathsContext> =>
85101
loadVaultPath().pipe(
86102
Effect.flatMap((vaultPath) =>
87103
listCachedPapers().pipe(
88104
Effect.mapError(cacheToVaultError),
89105
Effect.flatMap((result) => {
90-
const exported = Effect.forEach(result.entries, (entry): Effect.Effect<VaultExportResult, VaultError, CachePathsContext> => {
106+
const attempts = Effect.forEach(result.entries, (entry) => {
91107
const id = parsePaperIdentifier(entry.id)
92108
if (id === undefined) {
93-
return Effect.fail(new VaultCacheMalformed({ message: `invalid cached paper id: ${entry.id}`, id: entry.id }))
109+
return Effect.succeed<ExportAttempt>({
110+
_tag: "Skipped",
111+
id: entry.id,
112+
error: new VaultCacheMalformed({ message: `invalid cached paper id: ${entry.id}`, id: entry.id }),
113+
})
94114
}
95-
return exportOne(vaultPath, id)
115+
return exportOne(vaultPath, id).pipe(
116+
Effect.map((written): ExportAttempt => ({ _tag: "Exported", result: written })),
117+
Effect.catchTags({
118+
VaultCacheMissing: (error) => Effect.succeed<ExportAttempt>({ _tag: "Skipped", id: entry.id, error }),
119+
VaultCacheMalformed: (error) => Effect.succeed<ExportAttempt>({ _tag: "Skipped", id: entry.id, error }),
120+
}),
121+
)
96122
})
97-
return exported.pipe(Effect.map((written) => ({ count: written.length, path: vaultPath })))
123+
return attempts.pipe(Effect.map((all) => collectAttempts(all, vaultPath, result.entries.length)))
98124
})
99125
)
100126
)
101127
)
102128

129+
const collectAttempts = (attempts: ReadonlyArray<ExportAttempt>, vaultPath: string, total: number): VaultExportAllResult => {
130+
const exported: Array<VaultExportResult> = []
131+
const failed: Array<VaultExportFailure> = []
132+
for (const attempt of attempts) {
133+
if (attempt._tag === "Exported") exported.push(attempt.result)
134+
else failed.push({ id: attempt.id, error: attempt.error })
135+
}
136+
return { exported, failed, total, path: vaultPath }
137+
}
138+
103139
const exportOne = (vaultPath: string, id: PaperIdentifier): Effect.Effect<VaultExportResult, VaultError, CachePathsContext> => {
104140
const formattedId = formatIdentifier(id)
105141
return CachePaths.use((paths) => readCachedPaper(cacheDirForIdentifierAt(paths.cacheRoot, id), formattedId)).pipe(

tests/vault.test.ts

Lines changed: 68 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,24 @@ const writeEntry = (
9797
await writeFile(join(dir, "paper.md"), markdown, { encoding: "utf8" })
9898
})
9999

100+
const writeStaleEntry = (
101+
cacheRoot: string,
102+
dirname: string,
103+
meta: { readonly id: string; readonly title: string }
104+
) =>
105+
Effect.promise(async () => {
106+
const dir = join(cacheRoot, dirname)
107+
await mkdir(dir, { recursive: true })
108+
await writeFile(join(dir, "meta.json"), JSON.stringify(meta), { encoding: "utf8" })
109+
})
110+
111+
const writeConfiguredVault = (paths: { readonly configPath: string; readonly vaultPath: string }) =>
112+
Effect.promise(async () => {
113+
await mkdir(paths.vaultPath, { recursive: true })
114+
await mkdir(join(paths.configPath, ".."), { recursive: true })
115+
await writeFile(paths.configPath, `PAPER7_VAULT=${paths.vaultPath}\n`, { encoding: "utf8" })
116+
})
117+
100118
describe("vault commands", () => {
101119
it.effect("initializes vault config through Effect CLI", () =>
102120
withTempVault((paths) => Effect.gen(function*() {
@@ -188,11 +206,7 @@ describe("vault commands", () => {
188206

189207
it.effect("exports all cached papers with path-safe filenames", () =>
190208
withTempVault((paths) => Effect.gen(function*() {
191-
yield* Effect.promise(async () => {
192-
await mkdir(paths.vaultPath, { recursive: true })
193-
await mkdir(join(paths.configPath, ".."), { recursive: true })
194-
await writeFile(paths.configPath, `PAPER7_VAULT=${paths.vaultPath}\n`, { encoding: "utf8" })
195-
})
209+
yield* writeConfiguredVault(paths)
196210
yield* writeEntry(paths.cacheRoot, "2401.04088", { id: "2401.04088", title: "Fixture Get Paper" }, "# Fixture Get Paper\n")
197211
yield* writeEntry(paths.cacheRoot, "pmid-38903003", { id: "pmid:38903003", title: "Fixture PubMed Paper" }, "# Fixture PubMed Paper\n")
198212
yield* writeEntry(paths.cacheRoot, "doi-10.1101_2023.12.15.571821", { id: "doi:10.1101/2023.12.15.571821", title: "Unsafe / DOI: Paper" }, "# Unsafe / DOI: Paper\n")
@@ -204,9 +218,57 @@ describe("vault commands", () => {
204218

205219
expect(result.exit._tag).toBe("Success")
206220
expect(result.stderr).toBe("")
207-
expect(result.stdout).toBe(`Exported 3 papers to ${paths.vaultPath}`)
221+
expect(result.stdout).toBe(`Exported 3 of 3 papers to ${paths.vaultPath}`)
208222
expect(arxiv).toContain("# Fixture Get Paper")
209223
expect(pubmed).toContain("# Fixture PubMed Paper")
210224
expect(doi).toContain("# Unsafe / DOI: Paper")
211225
})))
226+
227+
it.effect("vault all skips stale cache entries and exports the rest (issue #19)", () =>
228+
withTempVault((paths) => Effect.gen(function*() {
229+
yield* writeConfiguredVault(paths)
230+
yield* writeEntry(paths.cacheRoot, "2401.04088", { id: "2401.04088", title: "Good Paper One" }, "# Good Paper One\n")
231+
yield* writeStaleEntry(paths.cacheRoot, "2402.99999", { id: "2402.99999", title: "Stale Paper" })
232+
yield* writeEntry(paths.cacheRoot, "2403.04088", { id: "2403.04088", title: "Good Paper Two" }, "# Good Paper Two\n")
233+
234+
const result = yield* runRootWith(paths.cacheRoot, paths.configPath, ["vault", "all"])
235+
const first = yield* Effect.promise(() => readFile(join(paths.vaultPath, "2401.04088.md"), { encoding: "utf8" }))
236+
const third = yield* Effect.promise(() => readFile(join(paths.vaultPath, "2403.04088.md"), { encoding: "utf8" }))
237+
238+
expect(result.exit._tag).toBe("Success")
239+
expect(result.stderr).toBe("")
240+
expect(result.stdout).toContain(`Exported 2 of 3 papers to ${paths.vaultPath}`)
241+
expect(result.stdout).toContain("Skipped:")
242+
expect(result.stdout).toContain("2402.99999")
243+
expect(result.stdout).toContain("no cached paper for 2402.99999")
244+
expect(first).toContain("# Good Paper One")
245+
expect(third).toContain("# Good Paper Two")
246+
})))
247+
248+
it.effect("vault all fails with exit 1 when every cache entry is broken", () =>
249+
withTempVault((paths) => Effect.gen(function*() {
250+
yield* writeConfiguredVault(paths)
251+
yield* writeStaleEntry(paths.cacheRoot, "2401.99999", { id: "2401.99999", title: "Broken One" })
252+
yield* writeStaleEntry(paths.cacheRoot, "2402.99999", { id: "2402.99999", title: "Broken Two" })
253+
254+
const result = yield* runRootWith(paths.cacheRoot, paths.configPath, ["vault", "all"])
255+
256+
expect(result.exit._tag).toBe("Failure")
257+
expect(result.stdout).toContain(`Exported 0 of 2 papers to ${paths.vaultPath}`)
258+
expect(result.stdout).toContain("Skipped:")
259+
expect(result.stdout).toContain("2401.99999")
260+
expect(result.stdout).toContain("2402.99999")
261+
expect(result.stderr).toBe("error: vault export failed: all cache entries failed to export")
262+
})))
263+
264+
it.effect("vault all on empty cache succeeds with a zero-count summary", () =>
265+
withTempVault((paths) => Effect.gen(function*() {
266+
yield* writeConfiguredVault(paths)
267+
268+
const result = yield* runRootWith(paths.cacheRoot, paths.configPath, ["vault", "all"])
269+
270+
expect(result.exit._tag).toBe("Success")
271+
expect(result.stderr).toBe("")
272+
expect(result.stdout).toBe(`Exported 0 of 0 papers to ${paths.vaultPath}`)
273+
})))
212274
})

0 commit comments

Comments
 (0)