1import { env, runInDurableObject } from "cloudflare:test";2import { describe, expect, it } from "vitest";3import { encodePack, type GitObject } from "ntig";4import { GitSqliteRepository } from "../../src/git-sqlite.ts";5import { GIT_SQLITE_SCHEMA } from "../../src/git-sqlite.ts";6import type { Relay } from "../../src/relay.ts";78const oid = async (type: string, data: Uint8Array) => {9 const header = new TextEncoder().encode(`${type} ${data.length}\0`);10 const bytes = new Uint8Array(header.length + data.length);11 bytes.set(header);12 bytes.set(data, header.length);13 return Array.from(new Uint8Array(await crypto.subtle.digest("SHA-1", bytes)), (value) => value.toString(16).padStart(2, "0")).join("");14};1516async function fixture(): Promise<{ pack: Uint8Array; commit: string }> {17 const blobData = new TextEncoder().encode("hello\n");18 const blob = await oid("blob", blobData);19 const treeData = new Uint8Array(6 + 5 + 1 + 20);20 treeData.set(new TextEncoder().encode("100644 file\0"));21 treeData.set(new Uint8Array(await hexBytes(blob)), 12);22 const tree = await oid("tree", treeData);23 const commitData = new TextEncoder().encode(`tree ${tree}\nauthor Test <test@example.com> 1 +0000\ncommitter Test <test@example.com> 1 +0000\n\nfirst\n`);24 const commit = await oid("commit", commitData);25 const objects: GitObject[] = [26 { oid: blob, type: "blob", data: blobData },27 { oid: tree, type: "tree", data: treeData },28 { oid: commit, type: "commit", data: commitData },29 ];30 return { pack: await encodePack(objects), commit };31}3233async function hexBytes(value: string): Promise<ArrayBuffer> {34 const bytes = new Uint8Array(value.length / 2);35 for (let index = 0; index < bytes.length; index++) bytes[index] = Number.parseInt(value.slice(index * 2, index * 2 + 2), 16);36 return bytes.buffer;37}3839function repository(relay: Relay, name: string, options: Partial<ConstructorParameters<typeof GitSqliteRepository>[0]> = {}) {40 relay.sql.exec(GIT_SQLITE_SCHEMA);41 return new GitSqliteRepository({ sql: relay.sql, repository: name, transactionSync: (closure) => relay.storage.transactionSync(closure), ...options });42}4344describe("GitSqliteRepository", () => {45 it("publishes compressed objects, refs and receipts in one DO transaction", async () => {46 await runInDurableObject(env.RELAY.getByName("git-sqlite-basic"), async (relay: Relay) => {47 const repo = repository(relay, "basic");48 const fixtureData = await fixture();49 const receipt = await repo.commit({ id: "first", updates: [{ name: "refs/heads/main", old: null, new: fixtureData.commit }], pack: fixtureData.pack });50 expect(receipt).toEqual({ id: "first", sequence: 1, replayed: false });51 expect((await repo.loadRefs()).refs).toEqual({ "refs/heads/main": fixtureData.commit });52 expect(await repo.commit({ id: "first", updates: [{ name: "refs/heads/main", old: null, new: fixtureData.commit }], pack: fixtureData.pack })).toEqual({ id: "first", sequence: 1, replayed: true });53 expect(repo.hasObjectSync(fixtureData.commit)).toBe(true);54 expect(relay.sql.exec<{ compressed_bytes: number }>("SELECT compressed_bytes FROM git_sqlite_meta WHERE repository=?", "basic").one().compressed_bytes).toBeGreaterThan(0);55 });56 });5758 it("keeps accepting commits after the legacy 128 record boundary", async () => {59 await runInDurableObject(env.RELAY.getByName("git-sqlite-many"), async (relay: Relay) => {60 const repo = repository(relay, "many");61 const fixtureData = await fixture();62 await repo.commit({ id: "seed", updates: [{ name: "refs/heads/main", old: null, new: fixtureData.commit }], pack: fixtureData.pack });63 let old = fixtureData.commit;64 for (let sequence = 1; sequence <= 130; sequence++) {65 const name = `refs/tags/t${sequence}`;66 await repo.commit({ id: `r${sequence}`, updates: [{ name, old: null, new: old }] });67 }68 expect((await repo.loadRefs()).sequence).toBe(131);69 });70 });7172 it("rolls back objects, refs and receipts when quota admission rejects", async () => {73 await runInDurableObject(env.RELAY.getByName("git-sqlite-quota"), async (relay: Relay) => {74 const repo = repository(relay, "quota", { quota: () => { throw new Error("quota rejected"); } });75 const fixtureData = await fixture();76 await expect(repo.commit({ id: "rejected", updates: [{ name: "refs/heads/main", old: null, new: fixtureData.commit }], pack: fixtureData.pack })).rejects.toThrow("quota rejected");77 expect((await repo.loadRefs()).sequence).toBe(0);78 expect(repo.hasObjectSync(fixtureData.commit)).toBe(false);79 expect(relay.sql.exec("SELECT 1 AS present FROM git_sqlite_receipts WHERE repository=?", "quota").toArray()).toHaveLength(0);80 });81 });8283 it("reports indexed links without reading an object body and detects stored corruption", async () => {84 await runInDurableObject(env.RELAY.getByName("git-sqlite-corruption"), async (relay: Relay) => {85 const meter: Array<{ rowsRead: number; rowsWritten: number }> = [];86 const repo = repository(relay, "corruption", { meter: (event) => meter.push(event) });87 const fixtureData = await fixture();88 await repo.commit({ id: "stored", updates: [{ name: "refs/heads/main", old: null, new: fixtureData.commit }], pack: fixtureData.pack });89 const info = await repo.getObjectInfo(fixtureData.commit);90 expect(info?.type).toBe("commit");91 expect(info?.links[0]?.type).toBe("tree");92 expect(meter.length).toBeGreaterThan(0);93 relay.sql.exec("UPDATE git_sqlite_object_chunks SET data=? WHERE repository=? AND oid=? AND chunk=0", new ArrayBuffer(1), "corruption", fixtureData.commit);94 await expect(repo.getObject(fixtureData.commit)).rejects.toThrow(/Corrupt Git object/);95 });96 });97});