Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/calm-badgers-rebuild.md
Original file line number Diff line number Diff line change
@@ -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.
6 changes: 5 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,11 @@ jobs:

test:
name: Test
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest]
runs-on: ${{ matrix.os }}

steps:
- name: Checkout code
Expand Down
6 changes: 6 additions & 0 deletions packages/context/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
50 changes: 14 additions & 36 deletions packages/context/src/cli.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,10 @@
#!/usr/bin/env node

import {
copyFileSync,
createWriteStream,
existsSync,
mkdirSync,
readdirSync,
renameSync,
statSync,
unlinkSync,
} from "node:fs";
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -497,7 +496,7 @@ function savePackageCopy(
destPath = join(resolvedSavePath, getPackageFileName(packageName, version));
}

copyFileSync(sourcePath, destPath);
copyPackageFile(sourcePath, destPath);
console.log(`✓ Saved to ${destPath}`);
}

Expand All @@ -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
Expand Down Expand Up @@ -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;
}
Expand All @@ -668,47 +667,26 @@ async function addFromUrl(
): Promise<void> {
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();
}
}

Expand Down
204 changes: 204 additions & 0 deletions packages/context/src/download.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof import("node:os")>();
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<typeof import("node:fs")>();
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<void>((resolve) => {
started = resolve;
});
const gate = new Promise<void>((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([]);
});
Loading