Skip to content

fix(#19): vault all skips broken cache entries instead of aborting#25

Merged
lucianfialho merged 2 commits into
p7dotorg:mainfrom
Vigtu:fix/issue-19-vault-all
May 22, 2026
Merged

fix(#19): vault all skips broken cache entries instead of aborting#25
lucianfialho merged 2 commits into
p7dotorg:mainfrom
Vigtu:fix/issue-19-vault-all

Conversation

@Vigtu

@Vigtu Vigtu commented May 15, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Rewrites exportAllPapersToVault so each cache entry is exported under an isolated Effect.catch, producing per-id ok/failed records instead of failing the whole batch on the first error.
  • Adds a VaultExportAllFailed tagged error and updates the vault all command to print a summary and exit 1 only when every entry failed.
  • Updates the renderer to show Exported N of M papers to <path> and a Skipped: block listing each failed <id> — <reason>.

Why

paper7 vault all used Effect.forEach in fail-fast mode. 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 from a research session) reliably hits at least one stale entry, so the only workaround today is scripting paper7 vault <id> per paper while swallowing failures. Skip-and-warn semantics fix that without giving up on noisy-but-recoverable cache state.

Behaviour matrix

Cache state exit stdout
All entries export successfully 0 Exported N of N papers to <path>
Some succeed, some fail 0 Exported K of N papers to <path> + Skipped: block
Every entry fails (with at least one entry) 1 summary on stdout + error: vault export failed: all cache entries failed to export on stderr
Cache is empty 0 Exported 0 of 0 papers to <path>

Test plan

  • npx vitest run → 171 pass / 0 fail (3 new vault tests + existing tests updated for the new summary format).
  • npx tsc --noEmit clean.
  • New tests cover: partial failure (mixed valid + stale entries, all valid ones exported, stale one in Skipped:), all-fail (exit 1 with new error), empty cache (exit 0, zero-count summary).

Closes #19.

…rting

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 p7dotorg#19.
Comment thread src/commands/vault.ts
Comment on lines +24 to +26
if (result.exported.length === 0 && result.total > 0) {
return yield* Effect.fail(new VaultExportAllFailed({ message: "all cache entries failed to export" }))
}

@EduSantosBrito EduSantosBrito May 21, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No need to Effect.fail here, since you used Data.TaggedError you can just yield it

@EduSantosBrito EduSantosBrito left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Effect-idiomatic review: requesting changes.

The product behavior is right: vault all should not throw away the whole batch because one cache entry is stale. The part I would tighten is the error boundary.

Why this matters in Effect: the E channel is not just an exception bucket; it is the typed control plane of the program. We should only recover from errors that the current operation semantically knows how to recover from. If we catch everything and turn it into a string, the caller loses the ability to distinguish "one bad cache entry" from "the vault cannot be written at all".

Concrete concern:

exportOne(vaultPath, id).pipe(
  Effect.map((written): ExportAttempt => ({ kind: "ok", result: written })),
  Effect.catch((error): Effect.Effect<ExportAttempt> =>
    Effect.succeed({ kind: "failed", id: entry.id, reason: error.message })
  )
)

This catches all VaultErrors. That includes expected per-entry problems like VaultCacheMissing, but also infrastructure/systemic problems like VaultFsError from permission errors, disk full, failed writes, etc. Those should probably fail the whole command loudly rather than being reported as a skipped paper.

Suggested Effect-style approach:

  • Convert only expected per-entry cache problems into skipped entries, e.g. with Effect.catchTags({ VaultCacheMissing, VaultCacheMalformed }).
  • Let VaultFsError, VaultInvalidPath, and config/path failures stay in the error channel.
  • Keep the failed entry payload typed for as long as possible; only turn it into a display string in the CLI renderer.

Something like:

exportOne(vaultPath, id).pipe(
  Effect.map((result): ExportAttempt => ({ _tag: "Exported", result })),
  Effect.catchTags({
    VaultCacheMissing: (error) => Effect.succeed({ _tag: "Skipped", id: entry.id, error }),
    VaultCacheMalformed: (error) => Effect.succeed({ _tag: "Skipped", id: entry.id, error })
  })
)

That preserves the intended skip-and-warn semantics without flattening the full typed error model.

Verification: I checked this PR locally with tsc --noEmit and the vault tests; they pass.

@EduSantosBrito

Copy link
Copy Markdown
Collaborator

Follow-up: a couple of Effect APIs would fit this PR especially well.

  1. Effect.catchTags

Use this to recover only from the cache-entry failures that are valid to skip:

exportOne(vaultPath, id).pipe(
  Effect.map((result): ExportAttempt => ({ _tag: "Exported", result })),
  Effect.catchTags({
    VaultCacheMissing: (error) => Effect.succeed({ _tag: "Skipped", id: entry.id, error }),
    VaultCacheMalformed: (error) => Effect.succeed({ _tag: "Skipped", id: entry.id, error })
  })
)

That leaves VaultFsError, invalid vault path/config problems, etc. in the error channel.

  1. Effect.result / Result.match

If the intention is truly "make this entry's failure data", Effect.result is a cleaner way to do that without flattening the error into a string too early:

const attempt = exportOne(vaultPath, id).pipe(Effect.result)

Then render later with Result.match, preserving the original typed error object until the CLI boundary.

Why this matters for someone new to Effect: catching an Effect error does not have to mean "turn it into a string". You can either catch specific tags, or intentionally turn the typed success/failure into data with Result, while keeping the useful error type information.

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.
@Vigtu

Vigtu commented May 21, 2026

Copy link
Copy Markdown
Contributor Author

Addressed both points in 6ca7743:

  • Narrowed skip boundary: replaced Effect.catch with Effect.catchTags({ VaultCacheMissing, VaultCacheMalformed }) in exportAllPapersToVault. VaultFsError, VaultInvalidPath, VaultConfigMissing now stay in the error channel and fail the whole command instead of being silently reported as a skipped paper. The previously-untagged "invalid cached paper id" case is now a typed VaultCacheMalformed.
  • Typed payload in ExportAttempt / VaultExportFailure: each failed entry now carries the VaultCacheMissing | VaultCacheMalformed instance; src/commands/vault.ts is the only place that stringifies it (entry.error.message). The visible Skipped: <id> — <message> line is unchanged, and all 10 vault tests still pass.
  • Inline at src/commands/vault.ts:25: dropped Effect.fail(new VaultExportAllFailed(...)) in favour of bare yield* new VaultExportAllFailed(...), matching the existing convention in the rest of the repo.

@Vigtu Vigtu requested a review from EduSantosBrito May 21, 2026 21:36
@lucianfialho lucianfialho merged commit 8065022 into p7dotorg:main May 22, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

bug: vault all aborts when any cached id has empty data

3 participants