File
Blob: test/util/packed-repo.ts
| 1 | import type { Head, Ref, RepoStateSchema } from "@/worker/do/repo/repoState"; |
| 2 | import type { TreeEntry } from "@/worker/git/operations/read/types"; |
| 3 | |
| 4 | import { concatChunks, encodeGitObject } from "@/worker/git/core"; |
| 5 | import { hexToBytes } from "@/worker/common"; |
| 6 | import { asTypedStorage, objKey } from "@/worker/do/repo/repoState"; |
| 7 | import { doPrefix, r2LooseKey, r2PackKey } from "@/worker/keys"; |
| 8 | import { |
| 9 | getDb, |
| 10 | listActivePackCatalog, |
| 11 | listPackCatalog, |
| 12 | upsertPackCatalogRow, |
| 13 | } from "@/worker/do/repo/db"; |
| 14 | import { buildPack } from "./git-pack"; |
| 15 | import { indexTestPack } from "./test-indexer"; |
| 16 | import { runDOWithRetry, type RepoDOStubFactory } from "./do-retry"; |
| 17 | |
| 18 | export type EncodedGitObject = Awaited<ReturnType<typeof encodeGitObject>>; |
| 19 | |
| 20 | export type SeedLegacyPackedRepoArgs = { |
| 21 | env: Env; |
| 22 | repoId: string; |
| 23 | getStub: RepoDOStubFactory; |
| 24 | packs: Array<{ name: string; packBytes: Uint8Array }>; |
| 25 | refs?: Ref[]; |
| 26 | head?: Head; |
| 27 | looseObjects?: EncodedGitObject[]; |
| 28 | mirrorLooseToR2?: boolean; |
| 29 | }; |
| 30 | |
| 31 | export type SeedPackedRepoResult = { |
| 32 | getStub: RepoDOStubFactory; |
| 33 | packKeys: string[]; |
| 34 | blob: EncodedGitObject; |
| 35 | tree: EncodedGitObject; |
| 36 | commit: EncodedGitObject; |
| 37 | tag: EncodedGitObject; |
| 38 | objectOids: string[]; |
| 39 | }; |
| 40 | |
| 41 | export type RepoCatalogStateSnapshot = { |
| 42 | packsetVersion: number; |
| 43 | nextPackSeq: number; |
| 44 | activeCatalog: Awaited<ReturnType<typeof listActivePackCatalog>>; |
| 45 | catalog: Awaited<ReturnType<typeof listPackCatalog>>; |
| 46 | }; |
| 47 | |
| 48 | export function buildTreePayload(entries: TreeEntry[]): Uint8Array { |
| 49 | const encoder = new TextEncoder(); |
| 50 | const parts: Uint8Array[] = []; |
| 51 | for (const entry of entries) { |
| 52 | parts.push( |
| 53 | encoder.encode(`${entry.mode} ${entry.name}`), |
| 54 | Uint8Array.from([0]), |
| 55 | hexToBytes(entry.oid) |
| 56 | ); |
| 57 | } |
| 58 | return concatChunks(parts); |
| 59 | } |
| 60 | |
| 61 | export async function seedPackedRepoState( |
| 62 | args: SeedLegacyPackedRepoArgs |
| 63 | ): Promise<{ getStub: RepoDOStubFactory; packKeys: string[] }> { |
| 64 | const packKeys: string[] = []; |
| 65 | |
| 66 | await runDOWithRetry(args.getStub, async (_instance, state) => { |
| 67 | const prefix = doPrefix(state.id.toString()); |
| 68 | const store = asTypedStorage<RepoStateSchema>(state.storage); |
| 69 | const db = getDb(state.storage); |
| 70 | const packSpecs = args.packs.map((pack) => ({ |
| 71 | ...pack, |
| 72 | packKey: r2PackKey(prefix, pack.name), |
| 73 | })); |
| 74 | |
| 75 | for (const obj of args.looseObjects ?? []) { |
| 76 | await store.put(objKey(obj.oid), obj.zdata); |
| 77 | if (args.mirrorLooseToR2) { |
| 78 | await args.env.REPO_BUCKET.put(r2LooseKey(prefix, obj.oid), obj.zdata); |
| 79 | } |
| 80 | } |
| 81 | |
| 82 | for (const pack of packSpecs) { |
| 83 | packKeys.push(pack.packKey); |
| 84 | } |
| 85 | |
| 86 | let nextSeq = (await store.get("nextPackSeq")) || 1; |
| 87 | const catalogSoFar: import("@/worker/do/repo/db/schema").PackCatalogRow[] = []; |
| 88 | // Historical test helpers passed packs newest-first because `packList` |
| 89 | // mirrored that ordering. Preserve that caller contract for assertions and |
| 90 | // returned `packKeys`, but index oldest-to-newest so REF_DELTA bases in |
| 91 | // older packs are visible while newer packs are being indexed. |
| 92 | for (const pack of [...packSpecs].reverse()) { |
| 93 | await args.env.REPO_BUCKET.put(pack.packKey, pack.packBytes); |
| 94 | |
| 95 | // Pass already-indexed packs so REF_DELTA bases in earlier packs can be resolved |
| 96 | const resolveResult = await indexTestPack( |
| 97 | args.env, |
| 98 | pack.packKey, |
| 99 | pack.packBytes.byteLength, |
| 100 | catalogSoFar.length > 0 ? catalogSoFar : undefined |
| 101 | ); |
| 102 | |
| 103 | const seq = nextSeq++; |
| 104 | const row: import("@/worker/do/repo/db/schema").PackCatalogRow = { |
| 105 | packKey: pack.packKey, |
| 106 | kind: "receive", |
| 107 | state: "active", |
| 108 | tier: 0, |
| 109 | seqLo: seq, |
| 110 | seqHi: seq, |
| 111 | objectCount: resolveResult.objectCount, |
| 112 | packBytes: pack.packBytes.byteLength, |
| 113 | idxBytes: resolveResult.idxBytes, |
| 114 | createdAt: Date.now(), |
| 115 | supersededBy: null, |
| 116 | }; |
| 117 | await upsertPackCatalogRow(db, row); |
| 118 | catalogSoFar.push(row); |
| 119 | } |
| 120 | |
| 121 | if (packKeys.length === 0) throw new Error("seedPackedRepoState requires at least one pack"); |
| 122 | |
| 123 | // Bump packset version and persist next sequence number |
| 124 | const packsetVersion = ((await store.get("packsetVersion")) || 0) + 1; |
| 125 | await store.put("packsetVersion", packsetVersion); |
| 126 | await store.put("nextPackSeq", nextSeq); |
| 127 | |
| 128 | if (args.refs) await store.put("refs", args.refs); |
| 129 | if (args.head) await store.put("head", args.head); |
| 130 | }); |
| 131 | |
| 132 | return { getStub: args.getStub, packKeys }; |
| 133 | } |
| 134 | |
| 135 | /** @deprecated Alias for backward compatibility during migration */ |
| 136 | export const seedLegacyPackedRepo = seedPackedRepoState; |
| 137 | |
| 138 | type SeedPackedRepoArgs = { |
| 139 | env: Env; |
| 140 | repoId: string; |
| 141 | getStub: RepoDOStubFactory; |
| 142 | options?: { mirrorLooseToR2?: boolean }; |
| 143 | }; |
| 144 | |
| 145 | export async function seedPackedRepo(args: SeedPackedRepoArgs): Promise<SeedPackedRepoResult>; |
| 146 | export async function seedPackedRepo( |
| 147 | env: Env, |
| 148 | repoId: string, |
| 149 | getStub: RepoDOStubFactory, |
| 150 | options?: { mirrorLooseToR2?: boolean } |
| 151 | ): Promise<SeedPackedRepoResult>; |
| 152 | export async function seedPackedRepo( |
| 153 | envOrArgs: Env | SeedPackedRepoArgs, |
| 154 | repoId?: string, |
| 155 | getStub?: RepoDOStubFactory, |
| 156 | options?: { mirrorLooseToR2?: boolean } |
| 157 | ): Promise<SeedPackedRepoResult> { |
| 158 | const args = |
| 159 | "env" in envOrArgs |
| 160 | ? envOrArgs |
| 161 | : { |
| 162 | env: envOrArgs, |
| 163 | repoId: repoId!, |
| 164 | getStub: getStub!, |
| 165 | options, |
| 166 | }; |
| 167 | |
| 168 | const author = "You <you@example.com> 0 +0000"; |
| 169 | const blobPayload = new TextEncoder().encode("hello from packed storage\n"); |
| 170 | const blob = await encodeGitObject("blob", blobPayload); |
| 171 | const treePayload = buildTreePayload([{ mode: "100644", name: "hello.txt", oid: blob.oid }]); |
| 172 | const tree = await encodeGitObject("tree", treePayload); |
| 173 | const commitPayload = new TextEncoder().encode( |
| 174 | `tree ${tree.oid}\n` + `author ${author}\n` + `committer ${author}\n\npacked commit\n` |
| 175 | ); |
| 176 | const commit = await encodeGitObject("commit", commitPayload); |
| 177 | const tagPayload = new TextEncoder().encode( |
| 178 | `object ${commit.oid}\n` + |
| 179 | `type commit\n` + |
| 180 | `tag v1\n` + |
| 181 | `tagger ${author}\n\n` + |
| 182 | `packed tag\n` |
| 183 | ); |
| 184 | const tag = await encodeGitObject("tag", tagPayload); |
| 185 | |
| 186 | const packBytes = await buildPack([ |
| 187 | { type: "blob", payload: blobPayload }, |
| 188 | { type: "tree", payload: treePayload }, |
| 189 | { type: "commit", payload: commitPayload }, |
| 190 | { type: "tag", payload: tagPayload }, |
| 191 | ]); |
| 192 | |
| 193 | const seeded = await seedPackedRepoState({ |
| 194 | env: args.env, |
| 195 | repoId: args.repoId, |
| 196 | getStub: args.getStub, |
| 197 | packs: [{ name: "pack-initial.pack", packBytes }], |
| 198 | refs: [ |
| 199 | { name: "refs/heads/main", oid: commit.oid }, |
| 200 | { name: "refs/tags/v1", oid: tag.oid }, |
| 201 | ], |
| 202 | head: { target: "refs/heads/main", oid: commit.oid }, |
| 203 | looseObjects: [blob, tree, commit, tag], |
| 204 | mirrorLooseToR2: args.options?.mirrorLooseToR2, |
| 205 | }); |
| 206 | |
| 207 | return { |
| 208 | ...seeded, |
| 209 | blob, |
| 210 | tree, |
| 211 | commit, |
| 212 | tag, |
| 213 | objectOids: [blob.oid, tree.oid, commit.oid, tag.oid], |
| 214 | }; |
| 215 | } |
| 216 | |
| 217 | export async function readRepoCatalogState( |
| 218 | getStub: RepoDOStubFactory |
| 219 | ): Promise<RepoCatalogStateSnapshot> { |
| 220 | return await runDOWithRetry(getStub, async (_instance, state: DurableObjectState) => { |
| 221 | const store = asTypedStorage<RepoStateSchema>(state.storage); |
| 222 | const db = getDb(state.storage); |
| 223 | return { |
| 224 | packsetVersion: (await store.get("packsetVersion")) || 0, |
| 225 | nextPackSeq: (await store.get("nextPackSeq")) || 0, |
| 226 | activeCatalog: await listActivePackCatalog(db), |
| 227 | catalog: await listPackCatalog(db), |
| 228 | }; |
| 229 | }); |
| 230 | } |
| 231 | |
| 232 | export async function deleteLooseObjectCopies(args: { |
| 233 | env: Env; |
| 234 | getStub: RepoDOStubFactory; |
| 235 | objectOids: string[]; |
| 236 | }): Promise<void>; |
| 237 | export async function deleteLooseObjectCopies( |
| 238 | env: Env, |
| 239 | getStub: RepoDOStubFactory, |
| 240 | objectOids: string[] |
| 241 | ): Promise<void>; |
| 242 | export async function deleteLooseObjectCopies( |
| 243 | envOrArgs: |
| 244 | | Env |
| 245 | | { |
| 246 | env: Env; |
| 247 | getStub: RepoDOStubFactory; |
| 248 | objectOids: string[]; |
| 249 | }, |
| 250 | getStub?: RepoDOStubFactory, |
| 251 | objectOids?: string[] |
| 252 | ): Promise<void> { |
| 253 | const args = |
| 254 | "env" in envOrArgs && "getStub" in envOrArgs && "objectOids" in envOrArgs |
| 255 | ? envOrArgs |
| 256 | : { |
| 257 | env: envOrArgs as Env, |
| 258 | getStub: getStub!, |
| 259 | objectOids: objectOids!, |
| 260 | }; |
| 261 | |
| 262 | await runDOWithRetry(args.getStub, async (_instance, state) => { |
| 263 | const prefix = doPrefix(state.id.toString()); |
| 264 | for (const oid of args.objectOids) { |
| 265 | await state.storage.delete(objKey(oid)); |
| 266 | await args.env.REPO_BUCKET.delete(r2LooseKey(prefix, oid)); |
| 267 | } |
| 268 | }); |
| 269 | } |
| 270 | |
| 271 | /** |
| 272 | * Build a pack from raw payloads, upload to R2, index, and register in pack_catalog. |
| 273 | * Use this to make loose-only test objects readable via the pack-first read path. |
| 274 | */ |
| 275 | export async function registerTestPack(args: { |
| 276 | env: Env; |
| 277 | repoId: string; |
| 278 | getStub: RepoDOStubFactory; |
| 279 | packName: string; |
| 280 | objects: Array<{ type: "commit" | "tree" | "blob" | "tag"; payload: Uint8Array }>; |
| 281 | }): Promise<string> { |
| 282 | const packBytes = await buildPack(args.objects); |
| 283 | |
| 284 | let packKey = ""; |
| 285 | await runDOWithRetry(args.getStub, async (_instance, state) => { |
| 286 | const prefix = doPrefix(state.id.toString()); |
| 287 | const store = asTypedStorage<RepoStateSchema>(state.storage); |
| 288 | const db = getDb(state.storage); |
| 289 | |
| 290 | packKey = r2PackKey(prefix, args.packName); |
| 291 | await args.env.REPO_BUCKET.put(packKey, packBytes); |
| 292 | |
| 293 | const resolveResult = await indexTestPack(args.env, packKey, packBytes.byteLength); |
| 294 | |
| 295 | const seq = (await store.get("nextPackSeq")) || 1; |
| 296 | await upsertPackCatalogRow(db, { |
| 297 | packKey, |
| 298 | kind: "receive", |
| 299 | state: "active", |
| 300 | tier: 0, |
| 301 | seqLo: seq, |
| 302 | seqHi: seq, |
| 303 | objectCount: resolveResult.objectCount, |
| 304 | packBytes: packBytes.byteLength, |
| 305 | idxBytes: resolveResult.idxBytes, |
| 306 | createdAt: Date.now(), |
| 307 | supersededBy: null, |
| 308 | }); |
| 309 | |
| 310 | const packsetVersion = ((await store.get("packsetVersion")) || 0) + 1; |
| 311 | await store.put("packsetVersion", packsetVersion); |
| 312 | await store.put("nextPackSeq", seq + 1); |
| 313 | }); |
| 314 | |
| 315 | return packKey; |
| 316 | } |