diff --git a/hooks/__tests__/use-bluesky-archive-import.test.ts b/hooks/__tests__/use-bluesky-archive-import.test.ts index d5b0d5b..0c96bf2 100644 --- a/hooks/__tests__/use-bluesky-archive-import.test.ts +++ b/hooks/__tests__/use-bluesky-archive-import.test.ts @@ -163,4 +163,49 @@ describe("importing a Cyd Bluesky archive from the menu", () => { expect(result.current.state.status).toBe("idle"); expect(fs.readdirSync(harness.stagingParent)).toEqual([]); }); + + /** + * Intake keeps a verified partial extraction so a killed import can be + * resumed (ADR 0006), and nothing in Mobile offers to resume one — so + * without this, every import the operating system interrupts leaves an + * archive-sized directory behind until the app is uninstalled. + */ + it("clears staging an earlier launch abandoned", async () => { + fs.mkdirSync(path.join(harness.stagingParent, "abandoned-with-nothing"), { + recursive: true, + }); + const halfDone = path.join(harness.stagingParent, "abandoned-part-way"); + fs.mkdirSync(halfDone, { recursive: true }); + fs.writeFileSync( + path.join(halfDone, "intake.json"), + JSON.stringify({ + sourceUri: "file:///fixtures/complete.cyd", + sourceBytes: 1, + phase: "extracting", + confirmedLargeArchive: false, + totalBytes: 1, + metadata: null, + payloads: [], + extracted: [], + updatedAt: new Date().toISOString(), + }), + ); + + const { result } = renderHook(() => + useBlueskyArchiveImport({ + runtime: harness.runtime, + }), + ); + + await act(async () => { + await result.current.start(); + }); + + expect(fs.existsSync(halfDone)).toBe(false); + expect( + fs.readdirSync(harness.stagingParent).filter((name) => + name.startsWith("abandoned-"), + ), + ).toEqual([]); + }); }); diff --git a/hooks/use-bluesky-archive-import.ts b/hooks/use-bluesky-archive-import.ts index 8d3894b..c2cd87f 100644 --- a/hooks/use-bluesky-archive-import.ts +++ b/hooks/use-bluesky-archive-import.ts @@ -5,6 +5,7 @@ import { useCallback, useRef, useState } from "react"; import { cancelBlueskyArchiveIntake, createBlueskyArchiveIntakeEnvironment, + listResumableBlueskyArchiveIntakes, openBlueskyArchiveByteReader, runBlueskyArchiveIntake, type BlueskyArchiveIntakeEnvironment, @@ -128,6 +129,33 @@ function messageFor(error: unknown): string { : "Cyd could not import that archive."; } +/** + * Clear staging that an earlier launch left behind. + * + * Intake keeps a verified partial extraction on purpose, so that an import + * killed by the operating system can be picked up rather than started over + * (ADR 0006). Nothing in Mobile offers to pick one up, so what that policy + * actually produces is a directory per abandoned import, each the size of the + * archive it was reading, kept until the app is uninstalled. + * + * Starting an import is the moment to sweep them: it is the only thing that + * creates them, the person is right there, and anything still in flight + * belongs to a generation this one has already moved past. The day something + * offers to resume an import, this is what it replaces. + */ +function discardAbandonedStaging( + intake: BlueskyArchiveIntakeEnvironment, +): void { + try { + for (const abandoned of listResumableBlueskyArchiveIntakes(intake)) { + cancelBlueskyArchiveIntake(intake, abandoned.intakeId); + } + } catch (error) { + // Debris nobody can see is not worth failing an import over. + console.warn("[archive-import] could not clear old staging", error); + } +} + function plural(count: number, noun: string): string { return `${count.toLocaleString()} ${noun}${count === 1 ? "" : "s"}`; } @@ -409,6 +437,7 @@ export function useBlueskyArchiveImport( const start = useCallback(async (): Promise => { const generation = (generationRef.current += 1); try { + discardAbandonedStaging(importRuntime().intake); const picked = await importRuntime().pickArchive(); if (!picked) { return;