File
Blob: test/multipack-union.worker.test.ts
| 1 | import { it, expect } from "vitest"; |
| 2 | import { env, exports as workerExports } from "cloudflare:workers"; |
| 3 | import { asTypedStorage } from "@/worker/do/repo/repoState"; |
| 4 | import type { RepoStateSchema } from "@/worker/do/repo/repoState"; |
| 5 | import { |
| 6 | concatChunks, |
| 7 | delimPkt, |
| 8 | encodeGitObject, |
| 9 | encodeObjHeader, |
| 10 | flushPkt, |
| 11 | pktLine, |
| 12 | decodePktLines, |
| 13 | } from "@/worker/git"; |
| 14 | import { uniqueRepoId, runDOWithRetry } from "./util/test-helpers"; |
| 15 | import { setupRepoForTests } from "./util/repoSeed"; |
| 16 | import { getDb, upsertPackCatalogRow } from "@/worker/do/repo/db"; |
| 17 | import { asBufferSource, deflate } from "@/worker/common"; |
| 18 | import { doPrefix, r2PackKey } from "@/worker/keys"; |
| 19 | import { indexTestPack } from "./util/test-indexer"; |
| 20 | import { bytesToHex } from "@/worker/common/hex"; |
| 21 | |
| 22 | async function buildPack(objs: { type: string; payload: Uint8Array }[]): Promise<Uint8Array> { |
| 23 | const hdr = new Uint8Array(12); |
| 24 | hdr.set(new TextEncoder().encode("PACK"), 0); |
| 25 | const dv = new DataView(hdr.buffer); |
| 26 | dv.setUint32(4, 2); |
| 27 | dv.setUint32(8, objs.length); |
| 28 | const parts: Uint8Array[] = [hdr]; |
| 29 | for (const o of objs) { |
| 30 | const typeCode = o.type === "commit" ? 1 : o.type === "tree" ? 2 : o.type === "blob" ? 3 : 4; |
| 31 | parts.push(encodeObjHeader(typeCode, o.payload.byteLength)); |
| 32 | parts.push(await deflate(o.payload)); |
| 33 | } |
| 34 | const body = concatChunks(parts); |
| 35 | const sha = new Uint8Array(await crypto.subtle.digest("SHA-1", asBufferSource(body))); |
| 36 | const out = new Uint8Array(body.byteLength + 20); |
| 37 | out.set(body, 0); |
| 38 | out.set(sha, body.byteLength); |
| 39 | return out; |
| 40 | } |
| 41 | |
| 42 | function buildFetchBody({ |
| 43 | wants, |
| 44 | haves, |
| 45 | done, |
| 46 | }: { |
| 47 | wants: string[]; |
| 48 | haves?: string[]; |
| 49 | done?: boolean; |
| 50 | }) { |
| 51 | const chunks: Uint8Array[] = []; |
| 52 | chunks.push(pktLine("command=fetch\n")); |
| 53 | chunks.push(delimPkt()); |
| 54 | for (const w of wants) chunks.push(pktLine(`want ${w}\n`)); |
| 55 | for (const h of haves || []) chunks.push(pktLine(`have ${h}\n`)); |
| 56 | if (done) chunks.push(pktLine("done\n")); |
| 57 | chunks.push(flushPkt()); |
| 58 | return concatChunks(chunks); |
| 59 | } |
| 60 | |
| 61 | it("multi-pack union assembles packfile from two R2 packs", async () => { |
| 62 | const owner = "o"; |
| 63 | const repo = uniqueRepoId("r-multipack"); |
| 64 | await setupRepoForTests(env, owner, repo); |
| 65 | const repoId = `${owner}/${repo}`; |
| 66 | const id = env.REPO_DO.idFromName(repoId); |
| 67 | const getStub = () => env.REPO_DO.get(id); |
| 68 | |
| 69 | // Create objects directly — no seedMinimalRepo needed |
| 70 | const treePayload = new Uint8Array(0); // empty tree |
| 71 | const { oid: treeOid } = await encodeGitObject("tree", treePayload); |
| 72 | |
| 73 | const author = "You <you@example.com> 0 +0000"; |
| 74 | const commitPayload = new TextEncoder().encode( |
| 75 | `tree ${treeOid}\nauthor ${author}\ncommitter ${author}\n\ninitial\n` |
| 76 | ); |
| 77 | const { oid: commitOid } = await encodeGitObject("commit", commitPayload); |
| 78 | |
| 79 | // Build two packs: A(commit), B(tree) — objects split across packs |
| 80 | const packA = await buildPack([{ type: "commit", payload: commitPayload }]); |
| 81 | const packB = await buildPack([{ type: "tree", payload: treePayload }]); |
| 82 | |
| 83 | // Upload packs, index with streaming indexer, register in pack_catalog |
| 84 | await runDOWithRetry(getStub, async (_instance, state: DurableObjectState) => { |
| 85 | const prefix = doPrefix(state.id.toString()); |
| 86 | const store = asTypedStorage<RepoStateSchema>(state.storage); |
| 87 | const db = getDb(state.storage); |
| 88 | |
| 89 | const keyA = r2PackKey(prefix, "pack-a.pack"); |
| 90 | const keyB = r2PackKey(prefix, "pack-b.pack"); |
| 91 | await env.REPO_BUCKET.put(keyA, packA); |
| 92 | await env.REPO_BUCKET.put(keyB, packB); |
| 93 | |
| 94 | const resolveA = await indexTestPack(env, keyA, packA.byteLength); |
| 95 | const resolveB = await indexTestPack(env, keyB, packB.byteLength); |
| 96 | |
| 97 | await upsertPackCatalogRow(db, { |
| 98 | packKey: keyA, |
| 99 | kind: "receive", |
| 100 | state: "active", |
| 101 | tier: 0, |
| 102 | seqLo: 1, |
| 103 | seqHi: 1, |
| 104 | objectCount: resolveA.objectCount, |
| 105 | packBytes: packA.byteLength, |
| 106 | idxBytes: resolveA.idxBytes, |
| 107 | createdAt: Date.now(), |
| 108 | supersededBy: null, |
| 109 | }); |
| 110 | await upsertPackCatalogRow(db, { |
| 111 | packKey: keyB, |
| 112 | kind: "receive", |
| 113 | state: "active", |
| 114 | tier: 0, |
| 115 | seqLo: 2, |
| 116 | seqHi: 2, |
| 117 | objectCount: resolveB.objectCount, |
| 118 | packBytes: packB.byteLength, |
| 119 | idxBytes: resolveB.idxBytes, |
| 120 | createdAt: Date.now(), |
| 121 | supersededBy: null, |
| 122 | }); |
| 123 | |
| 124 | await store.put("packsetVersion", 1); |
| 125 | await store.put("nextPackSeq", 3); |
| 126 | await store.put("refs", [{ name: "refs/heads/main", oid: commitOid }]); |
| 127 | await store.put("head", { target: "refs/heads/main", oid: commitOid }); |
| 128 | }); |
| 129 | |
| 130 | // Streaming v2: two-phase fetch. First negotiate (done=false) |
| 131 | const url = `https://example.com/${owner}/${repo}/git-upload-pack`; |
| 132 | const negotiate = await workerExports.default.fetch(url, { |
| 133 | method: "POST", |
| 134 | headers: { |
| 135 | "Content-Type": "application/x-git-upload-pack-request", |
| 136 | "Git-Protocol": "version=2", |
| 137 | }, |
| 138 | body: buildFetchBody({ wants: [commitOid], done: false }), |
| 139 | } as any); |
| 140 | expect(negotiate.status).toBe(200); |
| 141 | const negoText = new TextDecoder().decode(new Uint8Array(await negotiate.arrayBuffer())); |
| 142 | expect(negoText.includes("acknowledgments\n")).toBe(true); |
| 143 | expect(negoText.includes("packfile\n")).toBe(false); |
| 144 | |
| 145 | // Final fetch (done=true) returns only packfile section |
| 146 | const res = await workerExports.default.fetch(url, { |
| 147 | method: "POST", |
| 148 | headers: { |
| 149 | "Content-Type": "application/x-git-upload-pack-request", |
| 150 | "Git-Protocol": "version=2", |
| 151 | }, |
| 152 | body: buildFetchBody({ wants: [commitOid], done: true }), |
| 153 | } as any); |
| 154 | expect(res.status).toBe(200); |
| 155 | const bytes = new Uint8Array(await res.arrayBuffer()); |
| 156 | |
| 157 | // Extract sideband-encoded pack after the 'packfile' pkt-line |
| 158 | const lines = decodePktLines(bytes); |
| 159 | const packChunks: Uint8Array[] = []; |
| 160 | let inPackfile = false; |
| 161 | for (const line of lines) { |
| 162 | if (line.type === "line" && line.text === "packfile\n") { |
| 163 | inPackfile = true; |
| 164 | continue; |
| 165 | } |
| 166 | if (inPackfile && line.type === "line" && line.raw && line.raw[0] === 0x01) { |
| 167 | packChunks.push(line.raw.subarray(1)); |
| 168 | } |
| 169 | } |
| 170 | const packOut = concatChunks(packChunks); |
| 171 | |
| 172 | // Basic checks on assembled pack |
| 173 | const td = new TextDecoder(); |
| 174 | expect(td.decode(packOut.subarray(0, 4))).toBe("PACK"); |
| 175 | const dv = new DataView(packOut.buffer, packOut.byteOffset, packOut.byteLength); |
| 176 | expect(dv.getUint32(4, false)).toBe(2); |
| 177 | expect(dv.getUint32(8, false)).toBe(2); // commit + tree |
| 178 | |
| 179 | // Strong validation: upload the returned pack to R2 and index it to verify OIDs |
| 180 | const verifyKey = `verify/multipack-resp-${Date.now()}.pack`; |
| 181 | await env.REPO_BUCKET.put(verifyKey, packOut); |
| 182 | const verifyResult = await indexTestPack(env, verifyKey, packOut.byteLength); |
| 183 | expect(verifyResult.objectCount).toBe(2); |
| 184 | |
| 185 | // Extract OIDs from the idx view's rawNames and check both objects are present |
| 186 | const oidSet = new Set<string>(); |
| 187 | for (let i = 0; i < verifyResult.idxView.count; i++) { |
| 188 | const oidBytes = verifyResult.idxView.rawNames.subarray(i * 20, (i + 1) * 20); |
| 189 | oidSet.add(bytesToHex(oidBytes)); |
| 190 | } |
| 191 | expect(oidSet.has(commitOid)).toBe(true); |
| 192 | expect(oidSet.has(treeOid)).toBe(true); |
| 193 | }); |