From 8a4f4680b88851059842e2bfe99c9d4b238cf62f Mon Sep 17 00:00:00 2001 From: Martin Beckert Date: Wed, 9 Sep 2026 10:59:56 +0200 Subject: [PATCH 1/2] fix(context): preserve packages when replacement fails (#142) --- .changeset/calm-badgers-rebuild.md | 5 + .github/workflows/ci.yml | 6 +- packages/context/README.md | 6 + packages/context/src/cli.ts | 50 +--- packages/context/src/download.test.ts | 204 ++++++++++++++ packages/context/src/download.ts | 44 +-- packages/context/src/package-builder.ts | 20 +- packages/context/src/package-file.ts | 39 +++ packages/context/src/package-write.test.ts | 296 +++++++++++++++++++++ 9 files changed, 593 insertions(+), 77 deletions(-) create mode 100644 .changeset/calm-badgers-rebuild.md create mode 100644 packages/context/src/download.test.ts create mode 100644 packages/context/src/package-file.ts create mode 100644 packages/context/src/package-write.test.ts diff --git a/.changeset/calm-badgers-rebuild.md b/.changeset/calm-badgers-rebuild.md new file mode 100644 index 0000000..cba8611 --- /dev/null +++ b/.changeset/calm-badgers-rebuild.md @@ -0,0 +1,5 @@ +--- +"@neuledge/context": patch +--- + +Preserve installed documentation when a rebuild or install fails. Stage and validate replacements before renaming them into place, and keep temporary databases out of package discovery. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fc7606b..f0a1573 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -91,7 +91,11 @@ jobs: test: name: Test - runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest, macos-latest] + runs-on: ${{ matrix.os }} steps: - name: Checkout code diff --git a/packages/context/README.md b/packages/context/README.md index f8c182e..18f0f56 100644 --- a/packages/context/README.md +++ b/packages/context/README.md @@ -297,6 +297,12 @@ context add ./my-project --name my-lib --pkg-version 2.0 --save ./packages/ context add ./packages/my-lib@2.0.db ``` +Rebuilds and installs stage a replacement beside the destination, then close and +validate it before replacing the installed package. A failed build, download, or +replacement leaves the previous package available. Temporary files are excluded +from package discovery. If the operating system blocks replacement of an open +file (for example, on Windows), close the reader and retry the install. + --- ## :whale: Docker diff --git a/packages/context/src/cli.ts b/packages/context/src/cli.ts index 34baa43..d63f61e 100644 --- a/packages/context/src/cli.ts +++ b/packages/context/src/cli.ts @@ -1,12 +1,10 @@ #!/usr/bin/env node import { - copyFileSync, createWriteStream, existsSync, mkdirSync, readdirSync, - renameSync, statSync, unlinkSync, } from "node:fs"; @@ -55,6 +53,7 @@ import { buildPackage, type MarkdownFile, } from "./package-builder.js"; +import { copyPackageFile, createPackageTempFile } from "./package-file.js"; import { type SearchResult, search } from "./search.js"; import { ContextServer } from "./server.js"; import { @@ -497,7 +496,7 @@ function savePackageCopy( destPath = join(resolvedSavePath, getPackageFileName(packageName, version)); } - copyFileSync(sourcePath, destPath); + copyPackageFile(sourcePath, destPath); console.log(`✓ Saved to ${destPath}`); } @@ -507,13 +506,13 @@ function ensureDataDir(): void { } /** Load all packages from the data directory into the store. */ -function loadPackages(store: PackageStore): void { - if (!existsSync(DATA_DIR)) return; +export function loadPackages(store: PackageStore, directory = DATA_DIR): void { + if (!existsSync(directory)) return; - for (const file of readdirSync(DATA_DIR)) { - if (!file.endsWith(".db")) continue; + for (const file of readdirSync(directory)) { + if (!file.endsWith(".db") || file.startsWith(".downloading-")) continue; try { - const info = readPackageInfo(join(DATA_DIR, file)); + const info = readPackageInfo(join(directory, file)); store.add(info); } catch { // Skip invalid packages @@ -648,7 +647,7 @@ function addFromFile(source: string, options: { save?: string }): void { const destPath = join(DATA_DIR, destName); if (resolve(sourcePath) !== destPath) { - copyFileSync(sourcePath, destPath); + copyPackageFile(sourcePath, destPath); console.log(`✓ Copied to ${destPath}`); info.path = destPath; } @@ -668,47 +667,26 @@ async function addFromUrl( ): Promise { console.log(`Downloading ${url}...`); - // Extract filename from URL for temp file - const urlObj = new URL(url); - const filename = basename(urlObj.pathname) || "package.db"; - // Download to temp location first ensureDataDir(); - const tempPath = join(DATA_DIR, `.downloading-${Date.now()}-${filename}`); + const temp = createPackageTempFile(DATA_DIR); try { - await downloadFile(url, tempPath); + await downloadFile(url, temp.path); console.log(`✓ Downloaded`); // Validate the package - const info = readPackageInfo(tempPath); + const info = temp.install(); console.log(`✓ Validated package`); - // Move to final location - const destName = getPackageFileName(info.name, info.version); - const destPath = join(DATA_DIR, destName); - - // Remove old version if it exists - if (existsSync(destPath)) { - unlinkSync(destPath); - } - - // Rename temp to final - renameSync(tempPath, destPath); - info.path = destPath; - // Save to custom path if specified if (options.save) { - savePackageCopy(destPath, options.save, info.name, info.version); + savePackageCopy(info.path, options.save, info.name, info.version); } reportInstalled(info); - } catch (err) { - // Clean up temp file on error - if (existsSync(tempPath)) { - unlinkSync(tempPath); - } - throw err; + } finally { + temp.cleanup(); } } diff --git a/packages/context/src/download.test.ts b/packages/context/src/download.test.ts new file mode 100644 index 0000000..09dacfe --- /dev/null +++ b/packages/context/src/download.test.ts @@ -0,0 +1,204 @@ +import { + mkdirSync, + readdirSync, + readFileSync, + renameSync, + rmSync, +} from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + describe, + expect, + it, + vi, +} from "vitest"; +import { loadPackages } from "./cli.js"; +import { initDatabase } from "./database.js"; +import { downloadPackage } from "./download.js"; +import { buildPackage } from "./package-builder.js"; +import { PackageStore, readPackageInfo } from "./store.js"; + +vi.mock("node:os", async (importOriginal) => { + const os = await importOriginal(); + const fs = await import("node:fs"); + const path = await import("node:path"); + const home = fs.mkdtempSync(path.join(os.tmpdir(), "context-download-")); + return { ...os, homedir: () => home }; +}); +vi.mock("node:fs", async (importOriginal) => { + const fs = await importOriginal(); + return { ...fs, renameSync: vi.fn(fs.renameSync) }; +}); + +const DATA_DIR = join(homedir(), ".context", "packages"); +const PACKAGE_PATH = join(DATA_DIR, "test-lib@1.0.0.db"); +const OPTIONS = { name: "test-lib", version: "1.0.0" }; +const download = () => + downloadPackage("https://registry.example", "npm", "test-lib", "1.0.0"); +let payload: Uint8Array; + +beforeAll(async () => { + await initDatabase(); + const source = join(homedir(), "incoming.db"); + buildPackage( + source, + [{ path: "guide.md", content: "## Guide\n\nReplacement documentation." }], + OPTIONS, + ); + payload = new Uint8Array(readFileSync(source)); +}); +beforeEach(() => { + mkdirSync(DATA_DIR, { recursive: true }); + vi.stubGlobal( + "fetch", + vi.fn(async () => new Response(payload)), + ); +}); +afterEach(() => { + vi.restoreAllMocks(); + vi.resetAllMocks(); + vi.unstubAllGlobals(); + rmSync(DATA_DIR, { recursive: true, force: true }); +}); +afterAll(() => rmSync(homedir(), { recursive: true, force: true })); + +function seed(existing: boolean): Buffer | undefined { + if (!existing) return; + buildPackage( + PACKAGE_PATH, + [{ path: "old.md", content: "## Original\n\nOriginal documentation." }], + OPTIONS, + ); + return readFileSync(PACKAGE_PATH); +} + +describe.each([ + false, + true, +])("download (existing installation: %s)", (existing) => { + it.each([ + "network", + "stream", + "validation", + "replacement", + ])("preserves installed state after a %s failure", async (stage) => { + const original = seed(existing); + if (stage === "network") { + vi.mocked(fetch).mockRejectedValueOnce(new Error("Network unavailable")); + } else if (stage === "stream") { + let sent = false; + vi.mocked(fetch).mockResolvedValueOnce( + new Response( + new ReadableStream({ + pull(controller) { + if (sent) controller.error(new Error("Download interrupted")); + else { + controller.enqueue(payload.subarray(0, 128)); + sent = true; + } + }, + }), + ), + ); + } else if (stage === "validation") { + vi.mocked(fetch).mockResolvedValueOnce(new Response("invalid database")); + } else { + vi.mocked(renameSync).mockImplementationOnce(() => { + throw Object.assign(new Error("Destination is in use"), { + code: "EPERM", + }); + }); + } + + await expect(download()).rejects.toThrow(); + expect(readdirSync(DATA_DIR)).toEqual( + existing ? ["test-lib@1.0.0.db"] : [], + ); + if (original) { + expect(readFileSync(PACKAGE_PATH)).toEqual(original); + expect(readPackageInfo(PACKAGE_PATH).sectionCount).toBe(1); + } + }); + + it("publishes only the complete download and returns its installed path", async () => { + seed(existing); + let resume: () => void = () => {}; + let started: () => void = () => {}; + const paused = new Promise((resolve) => { + started = resolve; + }); + const gate = new Promise((resolve) => { + resume = resolve; + }); + let sent = false; + vi.mocked(fetch).mockResolvedValueOnce( + new Response( + new ReadableStream( + { + async pull(controller) { + if (!sent) { + sent = true; + controller.enqueue(payload.subarray(0, 128)); + } else { + started(); + await gate; + controller.enqueue(payload.subarray(128)); + controller.close(); + } + }, + }, + { highWaterMark: 0 }, + ), + ), + ); + + const pending = download(); + try { + await paused; + const store = new PackageStore(); + loadPackages(store, DATA_DIR); + expect(store.list().map((pkg) => pkg.sectionCount)).toEqual( + existing ? [1] : [], + ); + } finally { + resume(); + await pending; + } + expect(await pending).toMatchObject({ + ...OPTIONS, + path: PACKAGE_PATH, + sectionCount: 1, + }); + expect(new Uint8Array(readFileSync(PACKAGE_PATH))).toEqual(payload); + expect(readdirSync(DATA_DIR)).toEqual(["test-lib@1.0.0.db"]); + }); +}); + +it("uses independent staging files for simultaneous downloads of the same package", async () => { + vi.spyOn(Date, "now").mockReturnValue(1000); + const results = await Promise.all([download(), download()]); + expect(results.map((pkg) => pkg.path)).toEqual([PACKAGE_PATH, PACKAGE_PATH]); + expect(new Uint8Array(readFileSync(PACKAGE_PATH))).toEqual(payload); + expect(readdirSync(DATA_DIR)).toEqual(["test-lib@1.0.0.db"]); +}); + +it("ignores valid files left under legacy temporary download names", () => { + buildPackage( + join(DATA_DIR, ".downloading-legacy.db"), + [ + { + path: "guide.md", + content: "## Guide\n\nUnfinished download documentation.", + }, + ], + OPTIONS, + ); + const store = new PackageStore(); + loadPackages(store, DATA_DIR); + expect(store.list()).toEqual([]); +}); diff --git a/packages/context/src/download.ts b/packages/context/src/download.ts index 5a60e5d..97d7e7d 100644 --- a/packages/context/src/download.ts +++ b/packages/context/src/download.ts @@ -2,21 +2,12 @@ * Download and install documentation packages from a registry server. */ -import { - createWriteStream, - existsSync, - mkdirSync, - renameSync, - unlinkSync, -} from "node:fs"; +import { createWriteStream, mkdirSync } from "node:fs"; import { homedir } from "node:os"; import { join } from "node:path"; import { pipeline } from "node:stream/promises"; -import { - getPackageFileName, - type PackageInfo, - readPackageInfo, -} from "./store.js"; +import { createPackageTempFile } from "./package-file.js"; +import type { PackageInfo } from "./store.js"; const DATA_DIR = join(homedir(), ".context", "packages"); @@ -75,37 +66,18 @@ export async function downloadPackage( // Download to a temp file first, then validate and move mkdirSync(DATA_DIR, { recursive: true }); - const safeName = name.replaceAll("/", "__"); - const tempPath = join(DATA_DIR, `.downloading-${Date.now()}-${safeName}.db`); + const temp = createPackageTempFile(DATA_DIR); try { - const fileStream = createWriteStream(tempPath); + const fileStream = createWriteStream(temp.path); const { Readable } = await import("node:stream"); const nodeStream = Readable.fromWeb( response.body as import("stream/web").ReadableStream, ); await pipeline(nodeStream, fileStream); - // Validate the package - const info = readPackageInfo(tempPath); - - // Move to final location - const destPath = join( - DATA_DIR, - getPackageFileName(info.name, info.version), - ); - - if (existsSync(destPath)) { - unlinkSync(destPath); - } - renameSync(tempPath, destPath); - info.path = destPath; - - return info; - } catch (err) { - if (existsSync(tempPath)) { - unlinkSync(tempPath); - } - throw err; + return temp.install(); + } finally { + temp.cleanup(); } } diff --git a/packages/context/src/package-builder.ts b/packages/context/src/package-builder.ts index a336c75..e82e88f 100644 --- a/packages/context/src/package-builder.ts +++ b/packages/context/src/package-builder.ts @@ -3,10 +3,11 @@ */ import { createHash } from "node:crypto"; -import { existsSync, unlinkSync } from "node:fs"; +import { dirname } from "node:path"; import { type DocSection, parseDocument } from "./build.js"; import { openDatabase } from "./database.js"; import { REMOVED_TAGS } from "./html.js"; +import { createPackageTempFile } from "./package-file.js"; /** * Generate a content hash for section deduplication. @@ -569,11 +570,22 @@ export function buildPackage( files: MarkdownFile[], options: PackageBuildOptions, ): BuildResult { - // Remove existing file if present - if (existsSync(outputPath)) { - unlinkSync(outputPath); + const temp = createPackageTempFile(dirname(outputPath)); + try { + const result = writePackage(temp.path, files, options); + // writePackage closes the database before validation and replacement. + temp.install(outputPath); + return { ...result, path: outputPath }; + } finally { + temp.cleanup(); } +} +function writePackage( + outputPath: string, + files: MarkdownFile[], + options: PackageBuildOptions, +): BuildResult { const db = openDatabase(outputPath); try { diff --git a/packages/context/src/package-file.ts b/packages/context/src/package-file.ts new file mode 100644 index 0000000..be02c47 --- /dev/null +++ b/packages/context/src/package-file.ts @@ -0,0 +1,39 @@ +/** Staging and replacement shared by package builds, copies, and downloads. */ +import { copyFileSync, mkdtempSync, renameSync, rmSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { getPackageFileName, readPackageInfo } from "./store.js"; + +export function createPackageTempFile(directory: string) { + // Keep staging on the destination filesystem for rename, and outside *.db + // discovery. A private directory also contains any SQLite journal files. + const tempDir = mkdtempSync(join(directory, ".context-")); + const path = join(tempDir, "package.tmp"); + + return { + path, + install(outputPath?: string) { + const info = readPackageInfo(path); + const destination = + outputPath ?? + join(directory, getPackageFileName(info.name, info.version)); + + // Rename replaces an existing file. Never unlink it first: a failed + // rename (including a Windows sharing violation) must preserve it. + renameSync(path, destination); + return { ...info, path: destination }; + }, + cleanup() { + rmSync(tempDir, { recursive: true, force: true }); + }, + }; +} + +export function copyPackageFile(sourcePath: string, outputPath: string): void { + const temp = createPackageTempFile(dirname(outputPath)); + try { + copyFileSync(sourcePath, temp.path); + temp.install(outputPath); + } finally { + temp.cleanup(); + } +} diff --git a/packages/context/src/package-write.test.ts b/packages/context/src/package-write.test.ts new file mode 100644 index 0000000..75a7e58 --- /dev/null +++ b/packages/context/src/package-write.test.ts @@ -0,0 +1,296 @@ +import { + copyFileSync, + existsSync, + mkdtempSync, + readdirSync, + readFileSync, + renameSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + afterEach, + beforeAll, + beforeEach, + describe, + expect, + it, + vi, +} from "vitest"; +import { loadPackages } from "./cli.js"; +import { initDatabase, openDatabase } from "./database.js"; +import { buildPackage } from "./package-builder.js"; +import { copyPackageFile, createPackageTempFile } from "./package-file.js"; +import { search } from "./search.js"; +import { PackageStore, readPackageInfo } from "./store.js"; + +vi.mock("node:fs", async (importOriginal) => { + const fs = await importOriginal(); + return { + ...fs, + copyFileSync: vi.fn(fs.copyFileSync), + renameSync: vi.fn(fs.renameSync), + }; +}); +vi.mock("./database.js", async (importOriginal) => { + const database = await importOriginal(); + return { ...database, openDatabase: vi.fn(database.openDatabase) }; +}); +vi.mock("./store.js", async (importOriginal) => { + const store = await importOriginal(); + return { ...store, readPackageInfo: vi.fn(store.readPackageInfo) }; +}); + +const OPTIONS = { name: "test-lib", version: "1.0.0" }; +const documents = (word: string) => [ + { + path: "docs/guide.md", + content: `## Guide\n\nDocumentation about ${word}.`, + }, +]; +let directory: string; +let outputPath: string; +let realOpen: typeof openDatabase; +let realReadInfo: typeof readPackageInfo; + +beforeAll(async () => { + await initDatabase(); + realReadInfo = ( + await vi.importActual("./store.js") + ).readPackageInfo; + realOpen = ( + await vi.importActual("./database.js") + ).openDatabase; +}); +beforeEach(() => { + directory = mkdtempSync(join(tmpdir(), "context-package-write-")); + outputPath = join(directory, "test-lib@1.0.0.db"); +}); +afterEach(() => { + vi.restoreAllMocks(); + vi.resetAllMocks(); + rmSync(directory, { recursive: true, force: true }); +}); + +function expectSearch(path: string, topic: string): void { + const db = openDatabase(path, { readonly: true }); + try { + expect(search(db, topic).results).toHaveLength(1); + } finally { + db.close(); + } +} + +function injectFailure(stage: string): void { + const failure = new Error(`Injected ${stage} failure`); + if (stage === "validation") { + vi.mocked(readPackageInfo).mockImplementationOnce(() => { + throw failure; + }); + } else if (stage === "replacement") { + vi.mocked(renameSync).mockImplementationOnce(() => { + throw failure; + }); + } else { + vi.mocked(openDatabase).mockImplementationOnce((path, options) => { + if (stage === "initialization") throw failure; + const db = realOpen(path, options); + if (stage === "chunk insertion") { + const prepare = db.prepare.bind(db); + vi.spyOn(db, "prepare").mockImplementation((sql) => { + const stmt = prepare(sql); + if (sql.includes("INSERT INTO chunks")) { + vi.spyOn(stmt, "run").mockImplementation(() => { + throw failure; + }); + } + return stmt; + }); + } else if (stage === "close") { + const close = db.close.bind(db); + vi.spyOn(db, "close").mockImplementation(() => { + close(); + throw failure; + }); + } else { + const exec = db.exec.bind(db); + vi.spyOn(db, "exec").mockImplementation((sql) => { + exec(sql); + if ( + (stage === "FTS creation" && + sql.includes("CREATE VIRTUAL TABLE")) || + (stage === "FTS indexing" && sql.includes("VALUES('rebuild')")) + ) { + throw failure; + } + }); + } + return db; + }); + } +} + +describe.each([ + false, + true, +])("package build (existing installation: %s)", (existing) => { + it.each([ + "initialization", + "FTS creation", + "chunk insertion", + "FTS indexing", + "close", + "validation", + "replacement", + ])("preserves the installed state after a failure during %s", (stage) => { + if (existing) buildPackage(outputPath, documents("original"), OPTIONS); + const original = existing ? readFileSync(outputPath) : undefined; + injectFailure(stage); + + expect(() => + buildPackage(outputPath, documents("replacement"), OPTIONS), + ).toThrow(`Injected ${stage} failure`); + expect(readdirSync(directory)).toEqual( + existing ? ["test-lib@1.0.0.db"] : [], + ); + if (original) { + expect(readFileSync(outputPath)).toEqual(original); + expectSearch(outputPath, "original"); + expect(readPackageInfo(outputPath).sectionCount).toBe(1); + } + }); + + it("installs a complete, searchable package and removes staging files", () => { + if (existing) buildPackage(outputPath, documents("original"), OPTIONS); + const result = buildPackage(outputPath, documents("replacement"), OPTIONS); + expect(result.path).toBe(outputPath); + expect(result.sectionCount).toBe(1); + expectSearch(outputPath, "replacement"); + expect(readdirSync(directory)).toEqual(["test-lib@1.0.0.db"]); + }); +}); + +it("keeps the installed package visible during writing and closes before validation", () => { + buildPackage(outputPath, documents("original"), OPTIONS); + const original = readFileSync(outputPath); + let writerClosed = false; + let observations = 0; + vi.mocked(openDatabase).mockImplementationOnce((path, options) => { + const db = realOpen(path, options); + const exec = db.exec.bind(db); + const close = db.close.bind(db); + vi.spyOn(db, "exec").mockImplementation((sql) => { + exec(sql); + const store = new PackageStore(); + loadPackages(store, directory); + expect(store.list().map((pkg) => pkg.sectionCount)).toEqual([1]); + expect(readFileSync(outputPath)).toEqual(original); + observations++; + }); + vi.spyOn(db, "close").mockImplementation(() => { + close(); + writerClosed = true; + }); + return db; + }); + vi.mocked(readPackageInfo).mockImplementation((path) => { + if (path !== outputPath) expect(writerClosed).toBe(true); + return realReadInfo(path); + }); + + buildPackage(outputPath, documents("replacement"), OPTIONS); + expect(observations).toBeGreaterThanOrEqual(2); + expectSearch(outputPath, "replacement"); +}); + +it("does not discover staged packages, even after they become valid databases", () => { + const temp = createPackageTempFile(directory); + try { + buildPackage(temp.path, documents("replacement"), OPTIONS); + const store = new PackageStore(); + loadPackages(store, directory); + expect(store.list()).toEqual([]); + temp.install(); + loadPackages(store, directory); + expect(store.list().map((pkg) => pkg.path)).toEqual([outputPath]); + } finally { + temp.cleanup(); + } + expect(readdirSync(directory)).toEqual(["test-lib@1.0.0.db"]); +}); + +it.each([ + "EPERM", + "EBUSY", +])("preserves the old package when rename fails with %s", (code) => { + buildPackage(outputPath, documents("original"), OPTIONS); + const original = readFileSync(outputPath); + vi.mocked(renameSync).mockImplementationOnce(() => { + throw Object.assign(new Error("Destination is in use"), { code }); + }); + expect(() => + buildPackage(outputPath, documents("replacement"), OPTIONS), + ).toThrow("Destination is in use"); + expect(readFileSync(outputPath)).toEqual(original); + expectSearch(outputPath, "original"); + expect(readdirSync(directory)).toEqual(["test-lib@1.0.0.db"]); +}); + +it("keeps an open reader usable during replacement or an OS sharing violation", () => { + buildPackage(outputPath, documents("original"), OPTIONS); + const original = readFileSync(outputPath); + const reader = openDatabase(outputPath, { readonly: true }); + try { + expect(search(reader, "original").results).toHaveLength(1); + try { + buildPackage(outputPath, documents("replacement"), OPTIONS); + expectSearch(outputPath, "replacement"); + } catch (error) { + // SQLite handles may prohibit replacement on Windows. That failure must + // preserve the installed file; never work around it by deleting first. + expect(process.platform).toBe("win32"); + expect(["EPERM", "EACCES", "EBUSY"]).toContain( + (error as NodeJS.ErrnoException).code, + ); + expect(readFileSync(outputPath)).toEqual(original); + } + expect(search(reader, "original").results).toHaveLength(1); + } finally { + reader.close(); + } + buildPackage(outputPath, documents("replacement"), OPTIONS); + expectSearch(outputPath, "replacement"); + expect(readdirSync(directory)).toEqual(["test-lib@1.0.0.db"]); +}); + +it.each([ + false, + true, +])("cleans up a partial copy (existing installation: %s)", (existing) => { + if (existing) buildPackage(outputPath, documents("original"), OPTIONS); + const original = existing ? readFileSync(outputPath) : undefined; + vi.mocked(copyFileSync).mockImplementationOnce((_source, destination) => { + writeFileSync(destination, "partial database"); + throw new Error("Copy interrupted"); + }); + expect(() => copyPackageFile("source.db", outputPath)).toThrow( + "Copy interrupted", + ); + expect(readdirSync(directory)).toEqual(existing ? ["test-lib@1.0.0.db"] : []); + if (original) expect(readFileSync(outputPath)).toEqual(original); +}); + +it("rejects an invalid staged database and cleans up its SQLite artifacts", () => { + const temp = createPackageTempFile(directory); + try { + writeFileSync(temp.path, "invalid database"); + writeFileSync(`${temp.path}-journal`, "leftover journal"); + expect(() => temp.install(outputPath)).toThrow(); + expect(existsSync(outputPath)).toBe(false); + } finally { + temp.cleanup(); + } + expect(readdirSync(directory)).toEqual([]); +}); From 9b1e885e7700d03d950fba259439c2809ed48bc3 Mon Sep 17 00:00:00 2001 From: Martin Beckert Date: Wed, 9 Sep 2026 11:32:15 +0200 Subject: [PATCH 2/2] ci: remove Windows test job from PR #147 --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f0a1573..c4ed185 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -94,7 +94,7 @@ jobs: strategy: fail-fast: false matrix: - os: [ubuntu-latest, windows-latest, macos-latest] + os: [ubuntu-latest, macos-latest] runs-on: ${{ matrix.os }} steps: