1import { env, runInDurableObject } from "cloudflare:test";2import { describe, expect, it } from "vitest";3import { encodePack, type GitObject } from "ntig";4import { zlibSync } from "fflate";5import { GitSqliteRepository, GIT_SQLITE_SCHEMA } from "../../src/git-sqlite.ts";6import type { Relay } from "../../src/relay.ts";78const encoder = new TextEncoder();910async function objectId(type: GitObject["type"], data: Uint8Array): Promise<string> {11 const header = encoder.encode(`${type} ${data.length}\0`);12 const input = new Uint8Array(header.length + data.length);13 input.set(header);14 input.set(data, header.length);15 return Array.from(new Uint8Array(await crypto.subtle.digest("SHA-1", input)), (byte) => byte.toString(16).padStart(2, "0")).join("");16}1718async function commitObjects(message: string, parent: string | null, blobData = encoder.encode(`${message}\n`)) {19 const blob = await objectId("blob", blobData);20 const treeData = new Uint8Array(12 + 20);21 treeData.set(encoder.encode("100644 file\0"));22 treeData.set(hexBytes(blob), 12);23 const tree = await objectId("tree", treeData);24 const parentLine = parent ? `parent ${parent}\n` : "";25 const commitData = encoder.encode(`tree ${tree}\n${parentLine}author Test <test@example.com> 1 +0000\ncommitter Test <test@example.com> 1 +0000\n\n${message}\n`);26 const commit = await objectId("commit", commitData);27 const objects: GitObject[] = [28 { oid: blob, type: "blob", data: blobData },29 { oid: tree, type: "tree", data: treeData },30 { oid: commit, type: "commit", data: commitData },31 ];32 return { commit, pack: await encodePack(objects), rawBytes: objects.reduce((size, object) => size + object.data.length, 0), objects };33}3435async function malformedGraph(wrongType: boolean) {36 const targetData = new Uint8Array();37 const target = await objectId("tree", targetData);38 const targetObject: GitObject = { oid: target, type: "tree", data: targetData };39 const targetOid = wrongType ? target : "f".repeat(40);40 const treeData = new Uint8Array(12 + 20);41 treeData.set(encoder.encode("100644 file\0"));42 treeData.set(hexBytes(targetOid), 12);43 const tree = await objectId("tree", treeData);44 const commitData = encoder.encode(`tree ${tree}\nauthor Test <test@example.com> 1 +0000\ncommitter Test <test@example.com> 1 +0000\n\nmalformed\n`);45 const commit = await objectId("commit", commitData);46 const objects: GitObject[] = [47 ...(wrongType ? [targetObject] : []),48 { oid: tree, type: "tree", data: treeData },49 { oid: commit, type: "commit", data: commitData },50 ];51 return { commit, pack: await encodePack(objects) };52}5354function hexBytes(value: string): Uint8Array {55 const result = new Uint8Array(value.length / 2);56 for (let index = 0; index < result.length; index++) result[index] = Number.parseInt(value.slice(index * 2, index * 2 + 2), 16);57 return result;58}5960function repo(relay: Relay, name: string, options: Partial<ConstructorParameters<typeof GitSqliteRepository>[0]> = {}) {61 relay.sql.exec(GIT_SQLITE_SCHEMA);62 return new GitSqliteRepository({63 sql: relay.sql,64 repository: name,65 transactionSync: (closure) => relay.storage.transactionSync(closure),66 ...options,67 });68}6970describe("GitSqliteRepository limits and transaction behavior", () => {71 it("keeps a distinct-object history beyond 128 commits and survives reconstruction", async () => {72 await runInDurableObject(env.RELAY.getByName("git-sqlite-history-limits"), async (relay: Relay) => {73 let repository = repo(relay, "history");74 let parent: string | null = null;75 for (let index = 0; index < 130; index++) {76 const next = await commitObjects(`commit-${index}`, parent);77 await repository.commit({ id: `commit-${index}`, updates: [{ name: "refs/heads/main", old: parent, new: next.commit }], pack: next.pack });78 parent = next.commit;79 }80 expect((await repository.loadRefs()).sequence).toBe(130);81 const objectCount = relay.sql.exec<{ count: number }>("SELECT COUNT(*) AS count FROM git_sqlite_objects WHERE repository=?", "history").one().count;82 expect(objectCount).toBe(390);8384 repository = repo(relay, "history");85 expect((await repository.loadRefs()).refs["refs/heads/main"]).toBe(parent);86 expect(await repository.getObject(parent!)).not.toBeNull();87 const before = relay.sql.exec<{ count: number }>("SELECT COUNT(*) AS count FROM git_sqlite_objects WHERE repository=?", "history").one().count;88 await repository.commit({ id: "duplicate-payload", updates: [{ name: "refs/tags/repeated", old: null, new: parent }] });89 expect(relay.sql.exec<{ count: number }>("SELECT COUNT(*) AS count FROM git_sqlite_objects WHERE repository=?", "history").one().count).toBe(before);90 });91 });9293 it("allows only one concurrent compare-and-set publication", async () => {94 await runInDurableObject(env.RELAY.getByName("git-sqlite-cas"), async (relay: Relay) => {95 const repository = repo(relay, "cas");96 const seed = await commitObjects("seed", null);97 await repository.commit({ id: "seed", updates: [{ name: "refs/heads/main", old: null, new: seed.commit }], pack: seed.pack });98 const left = await commitObjects("left", seed.commit);99 const right = await commitObjects("right", seed.commit);100 const results = await Promise.allSettled([101 repository.commit({ id: "left", updates: [{ name: "refs/heads/main", old: seed.commit, new: left.commit }], pack: left.pack }),102 repository.commit({ id: "right", updates: [{ name: "refs/heads/main", old: seed.commit, new: right.commit }], pack: right.pack }),103 ]);104 expect(results.filter((result) => result.status === "fulfilled")).toHaveLength(1);105 expect(results.filter((result) => result.status === "rejected" && /changed|Ref changed/.test(String(result.reason)))).toHaveLength(1);106 expect((await repository.loadRefs()).sequence).toBe(2);107 });108 });109110 it("rolls back the complete publication when onCommit fails, while afterCommit runs after commit", async () => {111 await runInDurableObject(env.RELAY.getByName("git-sqlite-hooks"), async (relay: Relay) => {112 const failing = repo(relay, "hook-failure", { onCommit: () => { throw new Error("hook failed"); } });113 const first = await commitObjects("hook", null);114 await expect(failing.commit({ id: "hook", updates: [{ name: "refs/heads/main", old: null, new: first.commit }], pack: first.pack })).rejects.toThrow("hook failed");115 expect((await failing.loadRefs()).sequence).toBe(0);116 expect(failing.hasObjectSync(first.commit)).toBe(false);117 expect(relay.sql.exec("SELECT 1 AS present FROM git_sqlite_receipts WHERE repository=?", "hook-failure").toArray()).toHaveLength(0);118119 let after = 0;120 const succeeding = repo(relay, "after-hook", { afterCommit: () => { after++; throw new Error("after failed"); } });121 const second = await commitObjects("after", null);122 await expect(succeeding.commit({ id: "after", updates: [{ name: "refs/heads/main", old: null, new: second.commit }], pack: second.pack })).rejects.toThrow("after failed");123 expect(after).toBe(1);124 expect((await succeeding.loadRefs()).sequence).toBe(1);125 });126 });127128 it("captures request data before asynchronous hashing", async () => {129 await runInDurableObject(env.RELAY.getByName("git-sqlite-request-capture"), async (relay: Relay) => {130 const repository = repo(relay, "capture");131 const next = await commitObjects("captured", null);132 const request = { id: "captured", updates: [{ name: "refs/heads/main", old: null, new: next.commit }], pack: next.pack };133 const pending = repository.commit(request);134 request.id = "mutated";135 request.updates[0].name = "refs/heads/mutated";136 request.pack.fill(0);137 await pending;138 expect((await repository.loadRefs()).refs["refs/heads/main"]).toBe(next.commit);139 expect(await repository.lookupRecord("captured")).not.toBeNull();140 });141 });142143 it("deduplicates an identical object pack on a later ref publication", async () => {144 await runInDurableObject(env.RELAY.getByName("git-sqlite-dedup-pack"), async (relay: Relay) => {145 const repository = repo(relay, "dedup");146 const next = await commitObjects("dedup", null);147 await repository.commit({ id: "first", updates: [{ name: "refs/heads/main", old: null, new: next.commit }], pack: next.pack });148 const before = relay.sql.exec<{ count: number; compressed: number }>("SELECT object_count AS count, compressed_bytes AS compressed FROM git_sqlite_meta WHERE repository=?", "dedup").one();149 await repository.commit({ id: "second", updates: [{ name: "refs/tags/repeated", old: null, new: next.commit }], pack: next.pack });150 const after = relay.sql.exec<{ count: number; compressed: number }>("SELECT object_count AS count, compressed_bytes AS compressed FROM git_sqlite_meta WHERE repository=?", "dedup").one();151 expect(after).toEqual(before);152 expect((await repository.loadRefs()).refs["refs/tags/repeated"]).toBe(next.commit);153 });154 });155156 it("runs authority checks again for an identical retry after access is revoked", async () => {157 await runInDurableObject(env.RELAY.getByName("git-sqlite-retry-authority"), async (relay: Relay) => {158 const repository = repo(relay, "retry-authority");159 const next = await commitObjects("retry", null);160 const request = { id: "retry", updates: [{ name: "refs/heads/main", old: null, new: next.commit }], pack: next.pack };161 await repository.commit(request);162 let revoked = false;163 await expect(repository.commit(request, () => { if (revoked) throw new Error("revoked"); })).resolves.toEqual({ id: "retry", sequence: 1, replayed: true });164 revoked = true;165 await expect(repository.commit(request, () => { if (revoked) throw new Error("revoked"); })).rejects.toThrow("revoked");166 expect((await repository.loadRefs()).sequence).toBe(1);167 });168 });169170 it("reports the actual rows read and written by SQL cursors", async () => {171 await runInDurableObject(env.RELAY.getByName("git-sqlite-metering"), async (relay: Relay) => {172 relay.sql.exec(GIT_SQLITE_SCHEMA);173 let observedRead = 0;174 let observedWritten = 0;175 const sql = {176 exec<T extends Record<string, SqlStorageValue>>(query: string, ...bindings: SqlStorageValue[]) {177 const cursor = relay.sql.exec<T>(query, ...bindings);178 const toArray = cursor.toArray.bind(cursor);179 return {180 toArray() { const rows = toArray(); observedRead += cursor.rowsRead; observedWritten += cursor.rowsWritten; return rows; },181 get rowsRead() { return cursor.rowsRead; },182 get rowsWritten() { return cursor.rowsWritten; },183 };184 },185 } as unknown as SqlStorage;186 let meteredRead = 0;187 let meteredWritten = 0;188 const repository = new GitSqliteRepository({ sql, repository: "metering", transactionSync: (closure) => relay.storage.transactionSync(closure), meter: (event) => { meteredRead += event.rowsRead; meteredWritten += event.rowsWritten; } });189 const next = await commitObjects("meter", null);190 await repository.commit({ id: "meter", updates: [{ name: "refs/heads/main", old: null, new: next.commit }], pack: next.pack });191 await repository.loadRefs();192 expect(meteredRead).toBe(observedRead);193 expect(meteredWritten).toBe(observedWritten);194 expect(meteredWritten).toBeGreaterThan(0);195 });196 });197198 it("enforces cumulative raw and object quotas across individually valid pushes", async () => {199 await runInDurableObject(env.RELAY.getByName("git-sqlite-cumulative-limits"), async (relay: Relay) => {200 const first = await commitObjects("first", null);201 const second = await commitObjects("second", first.commit);202 const rawLimited = repo(relay, "raw-cumulative", { limits: { maxRawBytes: first.rawBytes + 1 } });203 await rawLimited.commit({ id: "first", updates: [{ name: "refs/heads/main", old: null, new: first.commit }], pack: first.pack });204 await expect(rawLimited.commit({ id: "second", updates: [{ name: "refs/heads/main", old: first.commit, new: second.commit }], pack: second.pack })).rejects.toThrow();205 const objectLimited = repo(relay, "object-cumulative", { limits: { maxObjects: 3 } });206 await objectLimited.commit({ id: "first", updates: [{ name: "refs/heads/main", old: null, new: first.commit }], pack: first.pack });207 await expect(objectLimited.commit({ id: "second", updates: [{ name: "refs/heads/main", old: first.commit, new: second.commit }], pack: second.pack })).rejects.toThrow();208 });209 });210211 it("rejects dangling and wrong-type graph links before publication", async () => {212 await runInDurableObject(env.RELAY.getByName("git-sqlite-graph-validation"), async (relay: Relay) => {213 for (const [name, wrongType] of [["dangling", false], ["wrong-type", true]] as const) {214 const repository = repo(relay, name);215 const malformed = await malformedGraph(wrongType);216 await expect(repository.commit({ id: name, updates: [{ name: "refs/heads/main", old: null, new: malformed.commit }], pack: malformed.pack })).rejects.toThrow();217 expect((await repository.loadRefs()).sequence).toBe(0);218 }219 });220 });221222 it("enforces raw, compressed, object, pack, and metadata budgets", async () => {223 await runInDurableObject(env.RELAY.getByName("git-sqlite-budget-limits"), async (relay: Relay) => {224 const next = await commitObjects("budget", null, new Uint8Array(1024));225 const cases: Array<[string, Partial<import("../../src/git-sqlite.ts").GitSqliteLimits>]> = [226 ["raw", { maxTransactionRawBytes: 100 }],227 ["compressed", { maxCompressedBytes: 1 }],228 ["objects", { maxObjects: 2 }],229 ["pack", { maxPackBytes: next.pack.length - 1 }],230 ["metadata", { maxMetadataBytes: 1 }],231 ];232 for (const [name, limits] of cases) {233 const repository = repo(relay, `budget-${name}`, { limits });234 await expect(repository.commit({ id: name, updates: [{ name: "refs/heads/main", old: null, new: next.commit }], pack: next.pack })).rejects.toThrow();235 expect((await repository.loadRefs()).sequence).toBe(0);236 }237 });238 });239240 it("keeps retained capacity separate from one-operation decode capacity", async () => {241 await runInDurableObject(env.RELAY.getByName("git-sqlite-operation-limits"), async (relay: Relay) => {242 const next = await commitObjects("operation-limits", null);243 const retained = repo(relay, "operation-limits", { limits: { maxTransactionObjects: 8, maxTransactionRawBytes: 1024 * 1024 } });244 await retained.commit({ id: "seed", updates: [{ name: "refs/heads/main", old: null, new: next.commit }], pack: next.pack });245 const bounded = repo(relay, "operation-limits", { limits: { maxTransactionObjects: 2, maxTransactionRawBytes: 1024 * 1024 } });246 const snapshot = await bounded.load();247 expect(snapshot.refs["refs/heads/main"]).toBe(next.commit);248 expect(snapshot.packs).toHaveLength(0);249 expect(await bounded.getObject(next.commit)).not.toBeNull();250 });251 });252253 it("round trips a near-4 MiB incompressible blob through multiple chunks", async () => {254 await runInDurableObject(env.RELAY.getByName("git-sqlite-large-object"), async (relay: Relay) => {255 const blob = new Uint8Array(4 * 1024 * 1024);256 for (let offset = 0; offset < blob.length; offset += 65536) crypto.getRandomValues(blob.subarray(offset, offset + 65536));257 const next = await commitObjects("large", null, blob);258 const repository = repo(relay, "large", { limits: { maxPackBytes: 8 * 1024 * 1024, maxTransactionRawBytes: 8 * 1024 * 1024, maxRawBytes: 8 * 1024 * 1024, maxCompressedBytes: 8 * 1024 * 1024, chunkBytes: 256 * 1024 } });259 await repository.commit({ id: "large", updates: [{ name: "refs/heads/main", old: null, new: next.commit }], pack: next.pack });260 const stored = await repository.getObject(await objectId("blob", blob));261 expect(stored?.data).toEqual(blob);262 expect(relay.sql.exec<{ chunks: number }>("SELECT chunks FROM git_sqlite_objects WHERE repository=? AND oid=?", "large", stored!.oid).one().chunks).toBeGreaterThan(1);263 });264 });265266 it("round trips a 37 MiB object through indexed chunk reads", async () => {267 await runInDurableObject(env.RELAY.getByName("git-sqlite-large-indexed-object"), async (relay: Relay) => {268 const blob = new Uint8Array(37 * 1024 * 1024);269 const repository = repo(relay, "large-indexed", {270 limits: {271 maxPackBytes: 64 * 1024 * 1024,272 maxObjectBytes: 64 * 1024 * 1024,273 maxTransactionRawBytes: 64 * 1024 * 1024,274 maxRawBytes: 64 * 1024 * 1024,275 maxCompressedBytes: 64 * 1024 * 1024,276 },277 });278 const oid = await objectId("blob", blob);279 const compressed = zlibSync(blob);280 const chunkBytes = 512 * 1024;281 relay.sql.exec("INSERT INTO git_sqlite_objects(repository,oid,type,raw_size,compressed_size,chunks) VALUES(?,?,?,?,?,?)", "large-indexed", oid, "blob", blob.length, compressed.length, Math.ceil(compressed.length / chunkBytes));282 for (let position = 0, chunk = 0; position < compressed.length; position += chunkBytes, chunk++) relay.sql.exec("INSERT INTO git_sqlite_object_chunks(repository,oid,chunk,data) VALUES(?,?,?,?)", "large-indexed", oid, chunk, compressed.subarray(position, position + chunkBytes).slice().buffer);283 const stored = await repository.getObject(oid);284 expect(stored?.data.length).toBe(blob.length);285 expect(stored?.data[0]).toBe(0);286 expect(stored?.data[blob.length - 1]).toBe(0);287 });288 }, 30_000);289});