Skip to content
File

Blob: test/multipack-union.worker.test.ts

typescript194 lines
1import { it, expect } from "vitest";
2import { env, exports as workerExports } from "cloudflare:workers";
3import { asTypedStorage } from "@/worker/do/repo/repoState";
4import type { RepoStateSchema } from "@/worker/do/repo/repoState";
5import {
6 concatChunks,
7 delimPkt,
8 encodeGitObject,
9 encodeObjHeader,
10 flushPkt,
11 pktLine,
12 decodePktLines,
13} from "@/worker/git";
14import { uniqueRepoId, runDOWithRetry } from "./util/test-helpers";
15import { setupRepoForTests } from "./util/repoSeed";
16import { getDb, upsertPackCatalogRow } from "@/worker/do/repo/db";
17import { asBufferSource, deflate } from "@/worker/common";
18import { doPrefix, r2PackKey } from "@/worker/keys";
19import { indexTestPack } from "./util/test-indexer";
20import { bytesToHex } from "@/worker/common/hex";
21 
22async 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 
42function 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 
61it("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});