Skip to content
Merged
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
46 changes: 46 additions & 0 deletions src/format/flatbuffers/repo-parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -343,3 +343,49 @@ export function listTagsFromRepo(parsedRepo: ParsedRepo): string[] {
const { repo } = parsedRepo;
return listRefs((i) => repo.tags(i), repo.tagsLength());
}

const snapshotIndexCache = new WeakMap<ParsedRepo, Map<string, number>>();

function snapshotIndexById(parsedRepo: ParsedRepo): Map<string, number> {
let index = snapshotIndexCache.get(parsedRepo);
if (!index) {
index = new Map();
for (let i = 0; i < parsedRepo.snapshotsLength; i++) {
index.set(bytesKey(getSnapshotIdByIndex(parsedRepo, i)), i);
}
snapshotIndexCache.set(parsedRepo, index);
}
return index;
}

function bytesKey(bytes: Uint8Array): string {
return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
}

/**
* Resolve a snapshot's parent ID from the repo file's snapshots list.
*
* v2 snapshot files carry no parent pointer; ancestry lives only in the
* repo file as `parent_offset` indices.
*
* @returns Parent snapshot ID bytes, null for a root snapshot, or undefined
* if the snapshot is absent from the repo file
* @throws Error on corrupted data (invalid parent offset)
*/
export function resolveSnapshotParent(
parsedRepo: ParsedRepo,
snapshotId: Uint8Array,
): Uint8Array | null | undefined {
const index = snapshotIndexById(parsedRepo).get(bytesKey(snapshotId));
if (index === undefined) return undefined;

const parentOffset = parsedRepo.repo.snapshots(index)!.parentOffset();
if (parentOffset === -1) return null;
if (parentOffset < 0 || parentOffset >= parsedRepo.snapshotsLength) {
throw new Error(
`Invalid parent offset ${parentOffset} for snapshot at index ${index}, ` +
`snapshots array has ${parsedRepo.snapshotsLength} entries`,
);
}
return getSnapshotIdByIndex(parsedRepo, parentOffset);
}
32 changes: 30 additions & 2 deletions src/reader/repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
resolveTag,
listBranchesFromRepo,
listTagsFromRepo,
resolveSnapshotParent,
type ParsedRepo,
} from "../format/flatbuffers/repo-parser.js";
import { ReadSession } from "./session.js";
Expand Down Expand Up @@ -472,22 +473,49 @@ export class Repository {
metadata: Record<string, unknown>;
}> {
let current: ReadSession | null = session;
const seen = new Set<string>();

while (current) {
const id = encodeObjectId12(current.getSnapshotId());
if (seen.has(id)) {
throw new Error(`Cycle in snapshot history at ${id}`);
}
seen.add(id);

yield {
id: encodeObjectId12(current.getSnapshotId()),
id,
message: current.getMessage(),
flushedAt: current.getFlushedAt(),
metadata: current.getSnapshotMetadata(),
};

const parentId = current.getParentSnapshotId();
const parentId =
current.getParentSnapshotId() ??
(await this.resolveParentFromRepoInfo(current, options));
if (!parentId) break;

current = await this.checkoutSnapshot(parentId, options);
}
}

/** v2 snapshots record ancestry only in the repo file. */
private async resolveParentFromRepoInfo(
session: ReadSession,
options?: RequestOptions,
): Promise<Uint8Array | null> {
const repoInfo = await this.loadRepoInfo(options);
if (!repoInfo) return null;

const snapshotId = session.getSnapshotId();
const parent = resolveSnapshotParent(repoInfo, snapshotId);
if (parent === undefined) {
throw new Error(
`Snapshot ${encodeObjectId12(snapshotId)} not found in repo info`,
);
}
return parent;
}

/**
* Get the storage backend.
*/
Expand Down
41 changes: 40 additions & 1 deletion tests/format/flatbuffers/repo-parser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,13 @@ import {
listTagsFromRepo,
resolveBranch,
resolveTag,
resolveSnapshotParent,
} from "../../../src/format/flatbuffers/repo-parser.js";
import { encodeObjectId12 } from "../../../src/format/object-id.js";
import {
decodeObjectId12,
encodeObjectId12,
} from "../../../src/format/object-id.js";
import { withParentOffset } from "../../helpers.js";

// ESM equivalent of __dirname
const __dirname = dirname(fileURLToPath(import.meta.url));
Expand Down Expand Up @@ -96,6 +101,40 @@ describe("repo-parser", () => {
});
});

describe("resolveSnapshotParent", () => {
const repo = () => parseRepo(readFileSync(join(TEST_REPO_V2_PATH, "repo")));
const MAIN_HEAD = decodeObjectId12("2768FV3XW5ZXYM84XNNG");

it("should resolve a snapshot's parent", () => {
const parent = resolveSnapshotParent(repo(), MAIN_HEAD);
expect(encodeObjectId12(parent!)).toBe("ZA24GZGPNK3GH682Q1R0");
});

it("should return null for the root snapshot", () => {
const root = decodeObjectId12("1CECHNKREP0F1RSTCMT0");
expect(resolveSnapshotParent(repo(), root)).toBeNull();
});

it("should return undefined for a snapshot absent from the repo file", () => {
const unknown = new Uint8Array(12).fill(7);
expect(resolveSnapshotParent(repo(), unknown)).toBeUndefined();
});

it("should throw on an out-of-range parent offset", () => {
const corrupt = withParentOffset(repo(), 1, 99);
expect(() => resolveSnapshotParent(corrupt, MAIN_HEAD)).toThrow(
"Invalid parent offset 99",
);
});

it("should throw on a negative parent offset other than -1", () => {
const corrupt = withParentOffset(repo(), 1, -2);
expect(() => resolveSnapshotParent(corrupt, MAIN_HEAD)).toThrow(
"Invalid parent offset -2",
);
});
});

describe("virtualChunkContainers", () => {
it("includes the unnamed container with its parsed S3 store config", () => {
// test-repo-v2's single container is unnamed (name: null) but carries an
Expand Down
22 changes: 22 additions & 0 deletions tests/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
* Test utilities and helpers.
*/

import type { ParsedRepo } from "../src/format/flatbuffers/repo-parser.js";

// Redeclare globalThis types for TypeScript
declare global {
var FIXTURE_SERVER_PORT: number;
Expand All @@ -15,3 +17,23 @@ export function getFixtureUrl(repoName: string): string {
const baseUrl = globalThis.FIXTURE_SERVER_URL ?? "http://localhost:8765";
return `${baseUrl}/${repoName}`;
}

/**
* Return a copy of a parsed repo whose snapshot at `index` reports
* `parentOffset`, for simulating corrupted ancestry.
*/
export function withParentOffset(
parsed: ParsedRepo,
index: number,
parentOffset: number,
): ParsedRepo {
const repo = Object.create(parsed.repo) as ParsedRepo["repo"];
repo.snapshots = (i, obj) => {
const info = parsed.repo.snapshots(i, obj);
if (i !== index || !info) return info;
const patched = Object.create(info) as typeof info;
patched.parentOffset = () => parentOffset;
return patched;
};
return { ...parsed, repo };
}
73 changes: 73 additions & 0 deletions tests/reader/repository.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,12 @@ import { readFileSync, readdirSync } from "node:fs";
import { join, dirname } from "node:path";
import { fileURLToPath } from "node:url";
import { Repository } from "../../src/reader/repository.js";
import type { ReadSession } from "../../src/reader/session.js";
import {
parseRepo,
type ParsedRepo,
} from "../../src/format/flatbuffers/repo-parser.js";
import { withParentOffset } from "../helpers.js";
import {
MockStorage,
MockStorageNoList,
Expand Down Expand Up @@ -593,6 +599,73 @@ describe("Repository", () => {
});
});

describe("walkHistory (v2)", () => {
// v2 snapshot files carry no parent; ancestry comes from the repo file.
const V2_MAIN_HISTORY = [
{ id: "2768FV3XW5ZXYM84XNNG", message: "set virtual chunk" },
{ id: "ZA24GZGPNK3GH682Q1R0", message: "fill data" },
{ id: "7T7DSS71H2JNBBZQ9M8G", message: "empty structure" },
{ id: "1CECHNKREP0F1RSTCMT0", message: "Repository initialized" },
];

async function collect(repo: Repository, session: ReadSession) {
const entries = [];
for await (const entry of repo.walkHistory(session)) {
entries.push({ id: entry.id, message: entry.message });
}
return entries;
}

async function collectBranch(branch: string) {
const storage = loadRepoIntoMockStorage(TEST_REPO_V2_PATH);
const repo = await Repository.open({ storage });
return collect(repo, await repo.checkoutBranch(branch));
}

/** The oldest overwritten repo file, which predates the main head. */
function oldestRepoInfo(): Uint8Array {
const dir = join(TEST_REPO_V2_PATH, "overwritten");
return readFileSync(join(dir, readdirSync(dir).sort().at(-1)!));
}

it("should walk linear history from branch head to root", async () => {
expect(await collectBranch("main")).toEqual(V2_MAIN_HISTORY);
});

it("should walk history including diverged commits on feature branch", async () => {
expect(await collectBranch("my-branch")).toEqual([
{ id: "H7T8YA6KR5TH32F3PS10", message: "some more structure" },
{ id: "QTP2RTYS5YQW8RPSQ93G", message: "delete a chunk" },
...V2_MAIN_HISTORY,
]);
});

it("should throw for a snapshot absent from the repo info", async () => {
const storage = loadRepoIntoMockStorage(TEST_REPO_V2_PATH);
storage.addFile(REPO_INFO_PATH, oldestRepoInfo());
const repo = await Repository.open({ storage });

const session = await repo.checkoutSnapshot(V2_MAIN_HISTORY[0].id);
await expect(collect(repo, session)).rejects.toThrow(
`Snapshot ${V2_MAIN_HISTORY[0].id} not found in repo info`,
);
});

it("should throw on cyclic ancestry", async () => {
const storage = loadRepoIntoMockStorage(TEST_REPO_V2_PATH);
const parsed = parseRepo(await storage.getObject(REPO_INFO_PATH));
// Point "fill data" (index 5) back at the main head (index 1).
const corrupt = withParentOffset(parsed, 5, 1);
const repo = await Repository.open({ storage });
(repo as unknown as { repoInfo: ParsedRepo }).repoInfo = corrupt;

const session = await repo.checkoutBranch("main");
await expect(collect(repo, session)).rejects.toThrow(
"Cycle in snapshot history",
);
});
});

describe("formatVersion hint", () => {
it("should skip all validation when formatVersion is v1", async () => {
const storage = new MockStorageNoList({});
Expand Down
Loading