From 3f17a0ea4cb6d4c42dd05aeba40eedae6c324af3 Mon Sep 17 00:00:00 2001 From: Jarrod Servilla Date: Mon, 13 Jul 2026 14:26:00 -0400 Subject: [PATCH] fix(deck): don't wipe deck on unparseable bulk-replace input (#87) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace-mode intake diffed an empty parse against the whole deck, so a parse failure (e.g. malformed JSON) emitted a remove op per card and committed a mass delete under a "Saved — removed N" success message. Short-circuit replace mode with a warning-only no-op when the parse yields zero cards but non-empty warnings — a parse failure, distinct from a legitimately empty paste (zero cards, no warnings) that may intentionally clear the deck. Closes #87 --- lib/deck/io/__tests__/intake.test.ts | 20 ++++++++++++++++++++ lib/deck/io/intake.ts | 9 +++++++++ 2 files changed, 29 insertions(+) diff --git a/lib/deck/io/__tests__/intake.test.ts b/lib/deck/io/__tests__/intake.test.ts index 509555d..eba889d 100644 --- a/lib/deck/io/__tests__/intake.test.ts +++ b/lib/deck/io/__tests__/intake.test.ts @@ -434,6 +434,26 @@ describe("intakeDecklist — replace mode", () => { expect(result.updated).toBe(0); }); + it("aborts without wiping the deck when the paste fails to parse (issue #87)", async () => { + // Guard short-circuits before the deck is read or mutated, so no + // deckCard.findMany / applyChanges mocks are queued here on purpose. + const result = await intakeDecklist({ + deckId: "deck-1", + userId: "user-1", + // Detected as JSON (leading "{") but not valid JSON → zero parsed cards. + text: "{ not valid json", + mode: "replace", + }); + + expect(mockApplyChanges).not.toHaveBeenCalled(); + expect(mockDeckCardFindMany).not.toHaveBeenCalled(); + expect(result.applied).toBe(0); + expect(result.removed).toBe(0); + expect(result.warnings).toContain( + "Decklist looks like JSON but could not be parsed", + ); + }); + it("re-throws non-InvariantViolation errors from applyChanges", async () => { mockCardFindMany.mockResolvedValueOnce([ { id: 1, name: "Lightning Bolt" }, diff --git a/lib/deck/io/intake.ts b/lib/deck/io/intake.ts index e1b503b..c3a405f 100644 --- a/lib/deck/io/intake.ts +++ b/lib/deck/io/intake.ts @@ -155,6 +155,15 @@ export async function intakeDecklist(input: IntakeInput): Promise const warnings = [...resolved.warnings]; const unmatchedNames = resolved.unmatched.map((c) => c.name); + // Replace mode diffs the parsed list against the entire deck, so a parse + // that yielded nothing would delete every card. A parse failure produces + // zero parsed cards *with* warnings — distinct from a legitimately empty + // paste (zero cards, no warnings), which may intentionally clear the deck. + // Abort with a warning-only result rather than silently wiping the deck. + if (mode === "replace" && parsed.cards.length === 0 && warnings.length > 0) { + return { applied: 0, added: 0, removed: 0, updated: 0, unmatchedNames, warnings }; + } + const changes = mode === "append" ? asAdds(resolved) : await buildReplaceChanges(deckId, resolved);