- commit
- 5754a9ca78f02fb3f20e4a69f8016598eb2c79d9
- parent
- 621dbc9df32b2e23e64326e500a749160ea8f862
- author
- Dami <dami.etoile@gmail.com>
| A | src/ | 26 | ++ |
| A | src/ | 382 | ++++++++++++++++++++++++++++++ |
| M | src/ | 14 | +- |
| A | test/ | 251 | ++++++++++++++++++++ |
| A | test/ | 97 | ++++++++ |
@@ -0,0 +1,26 @@
1+// SQL Git namespaces retain their signed repository identity for quotas and2+// portable backups. Their hash is independent of the relay's hostname.3+import type { Relay } from "./relay.ts";4+5+export const GIT_CATALOG_SCHEMA = `CREATE TABLE IF NOT EXISTS git_sqlite_catalog (6+ repository TEXT PRIMARY KEY, owner TEXT NOT NULL, identifier TEXT NOT NULL, alternative INTEGER NOT NULL7+);8+CREATE INDEX IF NOT EXISTS git_sqlite_catalog_owner ON git_sqlite_catalog(owner);`;9+10+// sqliteGitBytes is a logical payload breakdown, already included in the11+// database's physical SQLite meter. It is never added to the R2 media meter.12+export function sqliteGitBytes(relay: Relay, owner?: string, namespace = ""): number {13+ const row = owner === undefined14+ ? gitSql<{ bytes: number }>(relay, "SELECT coalesce(sum(compressed_bytes + metadata_bytes),0) AS bytes FROM git_sqlite_meta").one()15+ : gitSql<{ bytes: number }>(relay, "SELECT coalesce(sum(m.compressed_bytes + m.metadata_bytes),0) AS bytes FROM git_sqlite_meta m LEFT JOIN git_sqlite_catalog c ON c.repository=m.repository WHERE c.owner=? OR m.repository=?", owner, namespace).one();16+ return row.bytes;17+}18+19+// gitSql accounts for SQL work outside the object store, including failed writes.20+export function gitSql<T extends Record<string, SqlStorageValue> = Record<string, SqlStorageValue>>(relay: Relay, query: string, ...bindings: SqlStorageValue[]) {21+ const cursor = relay.sql.exec<T>(query, ...bindings);22+ let rows: T[];23+ try { rows = cursor.toArray(); }24+ finally { relay.meterPush(cursor.rowsRead, cursor.rowsWritten); }25+ return { toArray: () => rows, one: () => { if (rows.length !== 1) throw new Error("Expected one Git SQL row"); return rows[0]; } };26+}@@ -0,0 +1,382 @@
1+import {2+ ConflictError,3+ IntegrityError,4+ LimitError,5+ type CommitRequest,6+ type GitRepository,7+ type Receipt,8+ type RefSnapshot,9+ type Snapshot,10+ type WalRecord,11+} from "ntig";12+import { decodePack, objectLinks, type GitObject, type GitObjectType } from "ntig";13+import { sha256, validateRefName } from "ntig";14+import { sha1 } from "@noble/hashes/legacy.js";15+import { bytesToHex } from "@noble/hashes/utils.js";16+import { zlibSync, unzlibSync } from "fflate";17+18+// The object store keeps one compressed, content-addressed row per Git object.19+// Chunks keep every SQLite value below the production BLOB limit, while refs20+// and retry receipts remain in the same transaction as publication.21+export const GIT_SQLITE_SCHEMA = `22+CREATE TABLE IF NOT EXISTS git_sqlite_objects (23+ repository TEXT NOT NULL,24+ oid TEXT NOT NULL,25+ type TEXT NOT NULL,26+ raw_size INTEGER NOT NULL,27+ compressed_size INTEGER NOT NULL,28+ chunks INTEGER NOT NULL,29+ PRIMARY KEY (repository, oid)30+);31+CREATE TABLE IF NOT EXISTS git_sqlite_object_chunks (32+ repository TEXT NOT NULL,33+ oid TEXT NOT NULL,34+ chunk INTEGER NOT NULL,35+ data BLOB NOT NULL,36+ PRIMARY KEY (repository, oid, chunk),37+ FOREIGN KEY (repository, oid) REFERENCES git_sqlite_objects(repository, oid) ON DELETE CASCADE38+);39+CREATE TABLE IF NOT EXISTS git_sqlite_object_links (40+ repository TEXT NOT NULL,41+ oid TEXT NOT NULL,42+ position INTEGER NOT NULL,43+ link_oid TEXT NOT NULL,44+ link_type TEXT NOT NULL,45+ PRIMARY KEY (repository, oid, position)46+);47+CREATE TABLE IF NOT EXISTS git_sqlite_refs (48+ repository TEXT NOT NULL,49+ name TEXT NOT NULL,50+ oid TEXT NOT NULL,51+ PRIMARY KEY (repository, name)52+);53+CREATE TABLE IF NOT EXISTS git_sqlite_receipts (54+ repository TEXT NOT NULL,55+ id TEXT NOT NULL,56+ sequence INTEGER NOT NULL,57+ request_hash TEXT NOT NULL,58+ record_hash TEXT NOT NULL,59+ updates TEXT NOT NULL,60+ pack_hash TEXT,61+ PRIMARY KEY (repository, id)62+);63+CREATE TABLE IF NOT EXISTS git_sqlite_meta (64+ repository TEXT PRIMARY KEY,65+ sequence INTEGER NOT NULL,66+ tip TEXT,67+ compressed_bytes INTEGER NOT NULL DEFAULT 0,68+ raw_bytes INTEGER NOT NULL DEFAULT 0,69+ object_count INTEGER NOT NULL DEFAULT 0,70+ metadata_bytes INTEGER NOT NULL DEFAULT 071+);72+CREATE INDEX IF NOT EXISTS git_sqlite_receipts_sequence ON git_sqlite_receipts(repository, sequence);73+`;74+75+export interface GitSqliteLimits {76+ maxPackBytes: number;77+ maxObjectBytes: number;78+ maxObjects: number;79+ maxRawBytes: number;80+ maxTransactionRawBytes: number;81+ maxCompressedBytes: number;82+ maxMetadataBytes: number;83+ maxRefs: number;84+ maxGraphEdges: number;85+ chunkBytes: number;86+}87+88+export const DEFAULT_GIT_SQLITE_LIMITS: Readonly<GitSqliteLimits> = Object.freeze({89+ maxPackBytes: 4 * 1024 * 1024,90+ maxObjectBytes: 4 * 1024 * 1024,91+ maxObjects: 4096,92+ maxRawBytes: 16 * 1024 * 1024,93+ maxTransactionRawBytes: 16 * 1024 * 1024,94+ maxCompressedBytes: 16 * 1024 * 1024,95+ maxMetadataBytes: 16 * 1024 * 1024,96+ maxRefs: 1024,97+ maxGraphEdges: 65_536,98+ chunkBytes: 512 * 1024,99+});100+101+type Sql = SqlStorage;102+type Bytes = ArrayBuffer;103+const ID = /^[a-zA-Z0-9_.:-]{1,128}$/;104+const OID = /^[0-9a-f]{40}$/;105+const ZERO = "0".repeat(40);106+107+const asBuffer = (bytes: Uint8Array): Bytes => bytes.slice().buffer;108+const asBytes = (value: ArrayBuffer | Uint8Array): Uint8Array =>109+ value instanceof Uint8Array ? new Uint8Array(value) : new Uint8Array(value);110+111+export interface GitSqliteCommitChange {112+ id: string;113+ sequence: number;114+ refs: Readonly<Record<string, string>>;115+ objects: readonly { oid: string; type: GitObjectType; rawSize: number; compressedSize: number }[];116+}117+118+export interface GitSqliteOptions {119+ sql: Sql;120+ repository?: string;121+ limits?: Partial<GitSqliteLimits>;122+ transactionSync: <T>(closure: () => T) => T;123+ meter?: (event: { rowsRead: number; rowsWritten: number; rawBytes: number; compressedBytes: number }) => void;124+ quota?: (event: { rawBytes: number; compressedBytes: number; metadataBytes: number }) => void;125+ onCommit?: (change: GitSqliteCommitChange) => void;126+ afterCommit?: (change: GitSqliteCommitChange) => void;127+}128+129+// GitSqliteRepository stores validated Git objects in one relay DO database.130+// The class implements ntig's repository contract so the HTTP and GRASP131+// adapters can use it once the object-reader seam is available.132+export class GitSqliteRepository implements GitRepository {133+ readonly prefix: string;134+ readonly limits: Readonly<GitSqliteLimits>;135+ readonly #sql: Sql;136+ readonly #transaction: <T>(closure: () => T) => T;137+ readonly #meter?: GitSqliteOptions["meter"];138+ readonly #quota?: GitSqliteOptions["quota"];139+ readonly #onCommit?: GitSqliteOptions["onCommit"];140+ readonly #afterCommit?: GitSqliteOptions["afterCommit"];141+142+ constructor(options: GitSqliteOptions) {143+ this.#sql = options.sql;144+ this.prefix = options.repository ?? "default";145+ if (!/^[a-zA-Z0-9:_-]{1,256}$/.test(this.prefix)) throw new Error("Invalid repository name");146+ this.limits = Object.freeze({ ...DEFAULT_GIT_SQLITE_LIMITS, ...options.limits });147+ for (const value of Object.values(this.limits))148+ if (!Number.isSafeInteger(value) || value < 1) throw new Error("Invalid Git SQLite limit");149+ if (this.limits.chunkBytes > 512 * 1024 || this.limits.maxObjectBytes > 4 * 1024 * 1024) throw new Error("Git SQLite limits exceed platform bounds");150+ if (!options.transactionSync) throw new Error("transactionSync is required for Git SQLite storage");151+ this.#transaction = options.transactionSync;152+ this.#meter = options.meter;153+ this.#quota = options.quota;154+ this.#onCommit = options.onCommit;155+ this.#afterCommit = options.afterCommit;156+157+ }158+159+ async withReadSession<T>(operation: (repository: GitSqliteRepository) => Promise<T>): Promise<T> {160+ return operation(this);161+ }162+163+ async loadRefs(): Promise<RefSnapshot> {164+ const meta = this.#meta();165+ const refs = this.#refs();166+ return { sequence: meta.sequence, tip: meta.tip, version: String(meta.sequence), refs };167+ }168+169+ async load(): Promise<Snapshot> {170+ const refs = await this.loadRefs();171+ const packs: Uint8Array[] = [];172+ // Compatibility callers receive a bounded pack. HTTP and reconciliation173+ // use indexed reads; backups export the compressed SQL rows directly.174+ const meta = this.#meta();175+ if (meta.rawBytes > this.limits.maxTransactionRawBytes || meta.objectCount > 4096) throw new LimitError("Git SQLite snapshot exceeds limit");176+ const ids = this.#exec<{ oid: string }>("SELECT oid FROM git_sqlite_objects WHERE repository=? ORDER BY oid LIMIT 4097", this.prefix).toArray();177+ if (ids.length > 4096) throw new LimitError("Git snapshot object limit exceeded");178+ const objects: GitObject[] = [];179+ for (const { oid } of ids) objects.push((await this.getObject(oid))!);180+ if (objects.length) packs.push(await this.#pack(objects));181+ return { ...refs, records: [], packs };182+ }183+184+ async getObject(oid: string): Promise<GitObject | null> {185+ if (!OID.test(oid)) throw new IntegrityError("Invalid object ID");186+ const row = this.#exec<{ type: GitObjectType; raw_size: number }>("SELECT type,raw_size FROM git_sqlite_objects WHERE repository=? AND oid=?", this.prefix, oid).toArray()[0];187+ if (!row) return null;188+ const data = this.#read(oid, row.raw_size);189+ if (bytesToHex(sha1.create().update(new TextEncoder().encode(`${row.type} ${data.length}\0`)).update(data).digest()) !== oid) throw new IntegrityError("Git object hash mismatch");190+ return { oid, type: row.type, data };191+ }192+193+ async getObjectInfo(oid: string): Promise<{ oid: string; type: GitObjectType; size: number; links: readonly { oid: string; type: GitObjectType }[] } | null> {194+ if (!OID.test(oid)) throw new IntegrityError("Invalid object ID");195+ const row = this.#exec<{ type: GitObjectType; raw_size: number }>("SELECT type,raw_size FROM git_sqlite_objects WHERE repository=? AND oid=?", this.prefix, oid).toArray()[0];196+ if (!row) return null;197+ const links = this.#exec<{ link_oid: string; link_type: GitObjectType }>("SELECT link_oid,link_type FROM git_sqlite_object_links WHERE repository=? AND oid=? ORDER BY position LIMIT ?", this.prefix, oid, this.limits.maxGraphEdges + 1).toArray().map((link) => ({ oid: link.link_oid, type: link.link_type }));198+ if (!["blob", "tree", "commit", "tag"].includes(row.type) || !Number.isSafeInteger(row.raw_size) || row.raw_size < 0 || row.raw_size > this.limits.maxObjectBytes || links.length > this.limits.maxGraphEdges || links.some(link => !OID.test(link.oid) || !["blob", "tree", "commit", "tag"].includes(link.type))) throw new IntegrityError("Corrupt Git object metadata");199+ return { oid, type: row.type, size: row.raw_size, links };200+ }201+202+ async lookupRecord(id: string): Promise<WalRecord | null> {203+ if (!ID.test(id)) throw new IntegrityError("Invalid request ID");204+ const row = this.#exec<{ sequence: number; request_hash: string; updates: string; pack_hash: string | null }>(205+ "SELECT sequence,request_hash,updates,pack_hash FROM git_sqlite_receipts WHERE repository=? AND id=?",206+ this.prefix,207+ id,208+ ).toArray()[0];209+ if (!row) return null;210+ return {211+ format: 1,212+ sequence: row.sequence,213+ parent: row.sequence > 1 ? this.#tipAt(row.sequence - 1) : null,214+ id,215+ requestHash: row.request_hash,216+ pack: row.pack_hash,217+ updates: JSON.parse(row.updates) as WalRecord["updates"],218+ };219+ }220+221+ hasObjectSync(oid: string): boolean {222+ return OID.test(oid) && this.#exec("SELECT 1 AS present FROM git_sqlite_objects WHERE repository=? AND oid=?", this.prefix, oid).toArray().length > 0;223+ }224+225+ async commit(request: CommitRequest, beforeCommit?: () => void): Promise<Receipt> {226+ const id = request.id;227+ if (!ID.test(id)) throw new IntegrityError("Invalid request ID");228+ if (request.updates.length > this.limits.maxRefs) throw new LimitError("Too many ref updates");229+ if (request.updates.length === 0) throw new IntegrityError("Missing ref updates");230+ const updates = request.updates.map((update) => ({ ...update })).sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0);231+ for (const update of updates) {232+ validateRefName(update.name);233+ if (update.old !== null && (!OID.test(update.old) || update.old === ZERO)) throw new IntegrityError("Invalid old ref");234+ if (update.new !== null && (!OID.test(update.new) || update.new === ZERO)) throw new IntegrityError("Invalid new ref");235+ }236+ if (new Set(updates.map((update) => update.name)).size !== updates.length) throw new IntegrityError("Duplicate ref update");237+ const pack = request.pack?.slice();238+ if (pack && pack.length > this.limits.maxPackBytes) throw new LimitError("pack exceeds limit");239+ const packHash = pack ? await sha256(pack) : null;240+ const digest = await sha256(new TextEncoder().encode(JSON.stringify({ id, updates, pack: packHash })));241+ const prior = this.#exec<{ sequence: number; request_hash: string }>("SELECT sequence,request_hash FROM git_sqlite_receipts WHERE repository=? AND id=?", this.prefix, id).toArray()[0];242+ if (prior) {243+ if (prior.request_hash !== digest) throw new IntegrityError("Retry ID was reused with different data");244+ this.#transaction(() => beforeCommit?.());245+ return { id, sequence: prior.sequence, replayed: true };246+ }247+ const captured = this.#meta();248+ const capturedRefs = this.#refs();249+ const decoded = pack ? await decodePack(pack, {250+ maxPackBytes: this.limits.maxPackBytes,251+ maxObjectBytes: this.limits.maxObjectBytes,252+ maxObjects: Math.min(4096, this.limits.maxObjects),253+ maxTotalObjectBytes: this.limits.maxTransactionRawBytes,254+ }, undefined, (oid) => this.getObject(oid)) : [];255+ if (decoded.length > this.limits.maxObjects) throw new LimitError("Too many objects");256+ const objects = new Map<string, GitObject>(decoded.map((object) => [object.oid, object]));257+ for (const object of decoded) {258+ const prior = await this.getObject(object.oid);259+ if (prior && (prior.type !== object.type || !equal(prior.data, object.data))) throw new IntegrityError("Object ID collision");260+ }261+ const refs = { ...capturedRefs };262+ for (const update of updates) {263+ const current = refs[update.name] ?? null;264+ if (current !== update.old) throw new ConflictError(`Ref changed: ${update.name}`);265+ if (update.new === null) delete refs[update.name];266+ else refs[update.name] = update.new;267+ }268+ if (Object.keys(refs).length > this.limits.maxRefs) throw new LimitError("Too many refs");269+ const refNames = new Set(Object.keys(refs));270+ for (const name of refNames) {271+ const parts = name.split("/");272+ for (let index = 2; index < parts.length; index++) if (refNames.has(parts.slice(0, index).join("/"))) throw new IntegrityError("Ref namespace collision");273+ }274+ await this.#validateGraph(objects, refs);275+ const additions: GitObject[] = [];276+ const seen = new Set<string>();277+ for (const object of decoded) if (!seen.has(object.oid) && !this.hasObjectSync(object.oid)) { seen.add(object.oid); additions.push(object); }278+ const stored = additions.map((object) => {279+ const compressed = zlibSync(object.data);280+ return { object, compressed, rawSize: object.data.length, compressedSize: compressed.length };281+ });282+ const rawBytes = stored.reduce((sum, item) => sum + item.rawSize, 0);283+ const compressedBytes = stored.reduce((sum, item) => sum + item.compressedSize, 0);284+ // Payload accounting includes repeated SQL keys and link/ref/receipt values.285+ // The physical database meter separately includes pages and indexes.286+ const utf8 = (value: string) => new TextEncoder().encode(value).length;287+ const objectMetadata = stored.reduce((sum, item) => sum + utf8(this.prefix) + 80 + Math.ceil(item.compressedSize / this.limits.chunkBytes) * (utf8(this.prefix) + 48) + objectLinks(item.object).length * (utf8(this.prefix) + 96), 0);288+ const refMetadata = updates.reduce((sum, update) => sum + (update.new === null ? -1 : update.old === null ? 1 : 0) * (utf8(this.prefix) + utf8(update.name) + 40), 0);289+ const metadataBytes = objectMetadata + refMetadata + utf8(this.prefix) + utf8(JSON.stringify(updates)) + utf8(id) + 160;290+ if (rawBytes > this.limits.maxTransactionRawBytes) throw new LimitError("Transaction raw-byte limit exceeded");291+ if (this.#meta().compressedBytes + compressedBytes > this.limits.maxCompressedBytes) throw new LimitError("Repository compressed-byte limit exceeded");292+ if (this.#meta().metadataBytes + metadataBytes > this.limits.maxMetadataBytes) throw new LimitError("Repository metadata limit exceeded");293+ const parent = captured.tip;294+ const recordHash = await sha256(new TextEncoder().encode(JSON.stringify({ sequence: captured.sequence + 1, parent, id: id, digest })));295+ const result = this.#transaction(() => this.#commitTransaction(id, updates, packHash, digest, recordHash, refs, capturedRefs, captured.sequence, captured.tip, stored, rawBytes, compressedBytes, metadataBytes, beforeCommit));296+ if (!result.replayed) this.#afterCommit?.({ id: result.id, sequence: result.sequence, refs, objects: stored.map((item) => ({ oid: item.object.oid, type: item.object.type, rawSize: item.rawSize, compressedSize: item.compressedSize })) });297+ return result;298+ }299+300+ #commitTransaction(id: string, updates: WalRecord["updates"], packHash: string | null, digest: string, recordHash: string, refs: Record<string, string>, capturedRefs: Record<string, string>, capturedSequence: number, capturedTip: string | null, stored: readonly { object: GitObject; compressed: Uint8Array; rawSize: number; compressedSize: number }[], rawBytes: number, compressedBytes: number, metadataBytes: number, beforeCommit?: () => void): Receipt {301+ const prior = this.#exec<{ sequence: number; request_hash: string }>("SELECT sequence,request_hash FROM git_sqlite_receipts WHERE repository=? AND id=?", this.prefix, id).toArray()[0];302+ if (prior) {303+ if (prior.request_hash !== digest) throw new IntegrityError("Retry ID was reused with different data");304+ beforeCommit?.();305+ return { id, sequence: prior.sequence, replayed: true };306+ }307+ this.#exec("INSERT OR IGNORE INTO git_sqlite_meta(repository,sequence,tip) VALUES(?,0,NULL)", this.prefix);308+ const meta = this.#meta();309+ const sequence = meta.sequence + 1;310+ if (meta.sequence !== capturedSequence || meta.tip !== capturedTip || JSON.stringify(this.#refs()) !== JSON.stringify(capturedRefs)) throw new ConflictError("Repository changed during Git preparation");311+ if (meta.objectCount + stored.length > this.limits.maxObjects || meta.rawBytes + rawBytes > this.limits.maxRawBytes || meta.compressedBytes + compressedBytes > this.limits.maxCompressedBytes || meta.metadataBytes + metadataBytes > this.limits.maxMetadataBytes) throw new LimitError("Repository storage limit exceeded");312+ const edgeCount = this.#exec<{ count: number }>("SELECT count(*) AS count FROM git_sqlite_object_links WHERE repository=?", this.prefix).toArray()[0].count;313+ const addedEdges = stored.reduce((count, item) => count + objectLinks(item.object).length, 0);314+ if (edgeCount + addedEdges > this.limits.maxGraphEdges) throw new LimitError("Repository graph edge limit exceeded");315+ beforeCommit?.();316+ const changeObjects: Array<GitSqliteCommitChange["objects"][number]> = [];317+ for (const item of stored) {318+ this.#exec("INSERT INTO git_sqlite_objects(repository,oid,type,raw_size,compressed_size,chunks) VALUES(?,?,?,?,?,?)", this.prefix, item.object.oid, item.object.type, item.rawSize, item.compressedSize, Math.ceil(item.compressed.length / this.limits.chunkBytes));319+ let chunk = 0;320+ for (let position = 0; position < item.compressed.length; position += this.limits.chunkBytes)321+ this.#exec("INSERT INTO git_sqlite_object_chunks(repository,oid,chunk,data) VALUES(?,?,?,?)", this.prefix, item.object.oid, chunk++, asBuffer(item.compressed.subarray(position, position + this.limits.chunkBytes)));322+ for (const [position, link] of objectLinks(item.object).entries()) this.#exec("INSERT INTO git_sqlite_object_links(repository,oid,position,link_oid,link_type) VALUES(?,?,?,?,?)", this.prefix, item.object.oid, position, link.oid, link.type);323+ changeObjects.push({ oid: item.object.oid, type: item.object.type, rawSize: item.rawSize, compressedSize: item.compressedSize });324+ }325+ for (const update of updates) {326+ if (update.new === null) this.#exec("DELETE FROM git_sqlite_refs WHERE repository=? AND name=?", this.prefix, update.name);327+ else this.#exec("INSERT INTO git_sqlite_refs(repository,name,oid) VALUES(?,?,?) ON CONFLICT(repository,name) DO UPDATE SET oid=excluded.oid", this.prefix, update.name, update.new);328+ }329+ this.#exec("INSERT INTO git_sqlite_receipts(repository,id,sequence,request_hash,record_hash,updates,pack_hash) VALUES(?,?,?,?,?,?,?)", this.prefix, id, sequence, digest, recordHash, JSON.stringify(updates), packHash);330+ this.#exec("UPDATE git_sqlite_meta SET sequence=?,tip=?,compressed_bytes=compressed_bytes+?,raw_bytes=raw_bytes+?,object_count=object_count+?,metadata_bytes=metadata_bytes+? WHERE repository=?", sequence, recordHash, compressedBytes, rawBytes, stored.length, metadataBytes, this.prefix);331+ this.#quota?.({ rawBytes, compressedBytes, metadataBytes });332+ this.#onCommit?.({ id, sequence, refs, objects: changeObjects });333+ return { id, sequence, replayed: false };334+ }335+336+ async #validateGraph(objects: ReadonlyMap<string, GitObject>, refs: Readonly<Record<string, string>>): Promise<void> {337+ let edges = 0;338+ for (const object of objects.values()) {339+ const links = objectLinks(object, this.limits.maxGraphEdges - edges);340+ edges += links.length;341+ if (edges > this.limits.maxGraphEdges) throw new LimitError("Object graph edge limit exceeded");342+ for (const link of links) {343+ const target = objects.get(link.oid) ?? await this.getObjectInfo(link.oid);344+ if (!target || target.type !== link.type) throw new IntegrityError(`Missing or wrong-type ${link.type}: ${link.oid}`);345+ }346+ }347+ for (const [name, oid] of Object.entries(refs)) {348+ validateRefName(name);349+ const target = objects.get(oid) ?? await this.getObjectInfo(oid);350+ if (!target) throw new IntegrityError(`Missing ref target: ${name}`);351+ if (name.startsWith("refs/heads/") && target.type !== "commit") throw new IntegrityError("Branch target must be a commit");352+ }353+ }354+355+ #exec<T extends Record<string, SqlStorageValue> = Record<string, SqlStorageValue>>(query: string, ...bindings: SqlStorageValue[]) {356+ const cursor = this.#sql.exec<T>(query, ...bindings);357+ let rows: T[];358+ try { rows = cursor.toArray(); }359+ finally { this.#meter?.({ rowsRead: cursor.rowsRead, rowsWritten: cursor.rowsWritten, rawBytes: 0, compressedBytes: 0 }); }360+ return { toArray: () => rows };361+ }362+363+ #meta() { return this.#exec<{ sequence: number; tip: string | null; compressedBytes: number; rawBytes: number; objectCount: number; metadataBytes: number }>("SELECT sequence,tip,compressed_bytes AS compressedBytes,raw_bytes AS rawBytes,object_count AS objectCount,metadata_bytes AS metadataBytes FROM git_sqlite_meta WHERE repository=?", this.prefix).toArray()[0] ?? { sequence: 0, tip: null, compressedBytes: 0, rawBytes: 0, objectCount: 0, metadataBytes: 0 }; }364+ #refs() { return Object.fromEntries(this.#exec<{ name: string; oid: string }>("SELECT name,oid FROM git_sqlite_refs WHERE repository=? ORDER BY name", this.prefix).toArray().map((row) => [row.name, row.oid])); }365+ #tipAt(sequence: number) { return this.#exec<{ record_hash: string }>("SELECT record_hash FROM git_sqlite_receipts WHERE repository=? AND sequence=?", this.prefix, sequence).toArray()[0]?.record_hash ?? null; }366+ #read(oid: string, rawSize: number): Uint8Array {367+ if (rawSize < 0 || rawSize > this.limits.maxObjectBytes) throw new LimitError("Git object exceeds limit");368+ const meta = this.#exec<{ compressed_size: number; chunks: number }>("SELECT compressed_size,chunks FROM git_sqlite_objects WHERE repository=? AND oid=?", this.prefix, oid).toArray()[0];369+ if (!meta || meta.chunks < 1 || meta.chunks > Math.ceil((this.limits.maxObjectBytes + 65536) / this.limits.chunkBytes) || meta.compressed_size > this.limits.maxObjectBytes + 65536) throw new IntegrityError("Corrupt Git object metadata");370+ const rows = this.#exec<{ chunk: number; data: ArrayBuffer }>("SELECT chunk,data FROM git_sqlite_object_chunks WHERE repository=? AND oid=? ORDER BY chunk LIMIT ?", this.prefix, oid, Math.ceil((this.limits.maxObjectBytes + 65536) / this.limits.chunkBytes) + 1).toArray();371+ if (rows.length !== meta.chunks || rows.some((row, index) => row.chunk !== index || row.data.byteLength > this.limits.chunkBytes)) throw new IntegrityError("Corrupt Git object chunks");372+ const compressed = join(rows.map((row) => asBytes(row.data)));373+ if (compressed.length !== meta.compressed_size) throw new IntegrityError("Corrupt Git object size");374+ const data = unzlibSync(compressed, { out: new Uint8Array(rawSize + 1) });375+ if (data.length !== rawSize) throw new IntegrityError("Corrupt Git object");376+ return data;377+ }378+ async #pack(objects: readonly GitObject[]) { const { encodePack } = await import("ntig"); return encodePack(objects); }379+}380+381+const equal = (a: Uint8Array, b: Uint8Array) => a.length === b.length && a.every((value, index) => value === b[index]);382+const join = (parts: readonly Uint8Array[]) => { const result = new Uint8Array(parts.reduce((size, part) => size + part.length, 0)); let position = 0; for (const part of parts) { result.set(part, position); position += part.length; } return result; };@@ -4,9 +4,11 @@
44 import { callbackOrigins } from "./push-policy.ts";55 import { pushTick, queuePush, nextPush, PUSH_SCHEMA } from "./push.ts";66 import { BACKUP_SCHEMA, backupBytes } from "./backups.ts";7+import { GIT_SQLITE_SCHEMA } from "./git-sqlite.ts";8+import { GIT_CATALOG_SCHEMA } from "./git-catalog.ts";79 import { DELIVERY_SCHEMA, deliveryTick, queueDelivery } from "./delivery.ts";810 import { graspTick, isGitPath, graspCORS } from "./grasp.ts";9-import { graspBytes, holdGrasp, graspVisible } from "./grasp-state.ts";11+import { holdGrasp, graspVisible } from "./grasp-state.ts";1012 import { queueMirrors } from "./site-mirror.ts";1113 import { syncSiteIndex, forgetSites, serveSite } from "./sites.ts";1214 import { DurableObject } from "cloudflare:workers";@@ -160,6 +162,8 @@ export class Relay extends DurableObject<Env> {
160162 this.sql.exec(PUSH_SCHEMA);161163 this.sql.exec(DELIVERY_SCHEMA);162164 this.sql.exec(BACKUP_SCHEMA);165+ this.sql.exec(GIT_SQLITE_SCHEMA);166+ this.sql.exec(GIT_CATALOG_SCHEMA);163167 this.store.hidden = this.settings.hiddenEvents;164168 this.store.searchMode = () => this.settings.policy.features.search;165169 this.fuel.init();@@ -504,6 +508,8 @@ export class Relay extends DurableObject<Env> {
504508 this.sql.exec(PUSH_SCHEMA);505509 this.sql.exec(DELIVERY_SCHEMA);506510 this.sql.exec(BACKUP_SCHEMA);511+ this.sql.exec(GIT_SQLITE_SCHEMA);512+ this.sql.exec(GIT_CATALOG_SCHEMA);507513 this.store.hidden = this.settings.hiddenEvents;508514 this.store.searchMode = () => this.settings.policy.features.search;509515 this.fuel.init();@@ -764,14 +770,14 @@ export class Relay extends DurableObject<Env> {
764770 });765771 }766772 767- // Events live in the object's SQLite database; media in R2. Priced apart.773+ // Events and SQL Git share the physical SQLite meter, including indexes.768774 eventBytes(): number {769775 return this.store.databaseSize;770776 }771777 772- // Files and dumps both live in R2 and both cost media storage.778+ // Files and archives remain in R2. SQL Git is counted above.773779 mediaBytes(): number {774- return blobBytes(this.sql) + dumpBytes(this.sql) + importBytes(this.sql) + graspBytes(this) + backupBytes(this);780+ return blobBytes(this.sql) + dumpBytes(this.sql) + importBytes(this.sql) + backupBytes(this);775781 }776782 777783 fuelStatus() {@@ -0,0 +1,251 @@
1+import { env, runInDurableObject } from "cloudflare:test";2+import { describe, expect, it } from "vitest";3+import { encodePack, type GitObject } from "ntig";4+import { GitSqliteRepository, GIT_SQLITE_SCHEMA } from "../../src/git-sqlite.ts";5+import type { Relay } from "../../src/relay.ts";6+7+const encoder = new TextEncoder();8+9+async function objectId(type: GitObject["type"], data: Uint8Array): Promise<string> {10+ const header = encoder.encode(`${type} ${data.length}\0`);11+ const input = new Uint8Array(header.length + data.length);12+ input.set(header);13+ input.set(data, header.length);14+ return Array.from(new Uint8Array(await crypto.subtle.digest("SHA-1", input)), (byte) => byte.toString(16).padStart(2, "0")).join("");15+}16+17+async function commitObjects(message: string, parent: string | null, blobData = encoder.encode(`${message}\n`)) {18+ const blob = await objectId("blob", blobData);19+ const treeData = new Uint8Array(12 + 20);20+ treeData.set(encoder.encode("100644 file\0"));21+ treeData.set(hexBytes(blob), 12);22+ const tree = await objectId("tree", treeData);23+ const parentLine = parent ? `parent ${parent}\n` : "";24+ 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`);25+ const commit = await objectId("commit", commitData);26+ const objects: GitObject[] = [27+ { oid: blob, type: "blob", data: blobData },28+ { oid: tree, type: "tree", data: treeData },29+ { oid: commit, type: "commit", data: commitData },30+ ];31+ return { commit, pack: await encodePack(objects), rawBytes: objects.reduce((size, object) => size + object.data.length, 0), objects };32+}33+34+async function malformedGraph(wrongType: boolean) {35+ const targetData = new Uint8Array();36+ const target = await objectId("tree", targetData);37+ const targetObject: GitObject = { oid: target, type: "tree", data: targetData };38+ const targetOid = wrongType ? target : "f".repeat(40);39+ const treeData = new Uint8Array(12 + 20);40+ treeData.set(encoder.encode("100644 file\0"));41+ treeData.set(hexBytes(targetOid), 12);42+ const tree = await objectId("tree", treeData);43+ const commitData = encoder.encode(`tree ${tree}\nauthor Test <test@example.com> 1 +0000\ncommitter Test <test@example.com> 1 +0000\n\nmalformed\n`);44+ const commit = await objectId("commit", commitData);45+ const objects: GitObject[] = [46+ ...(wrongType ? [targetObject] : []),47+ { oid: tree, type: "tree", data: treeData },48+ { oid: commit, type: "commit", data: commitData },49+ ];50+ return { commit, pack: await encodePack(objects) };51+}52+53+function hexBytes(value: string): Uint8Array {54+ const result = new Uint8Array(value.length / 2);55+ for (let index = 0; index < result.length; index++) result[index] = Number.parseInt(value.slice(index * 2, index * 2 + 2), 16);56+ return result;57+}58+59+function repo(relay: Relay, name: string, options: Partial<ConstructorParameters<typeof GitSqliteRepository>[0]> = {}) {60+ relay.sql.exec(GIT_SQLITE_SCHEMA);61+ return new GitSqliteRepository({62+ sql: relay.sql,63+ repository: name,64+ transactionSync: (closure) => relay.storage.transactionSync(closure),65+ ...options,66+ });67+}68+69+describe("GitSqliteRepository limits and transaction behavior", () => {70+ it("keeps a distinct-object history beyond 128 commits and survives reconstruction", async () => {71+ await runInDurableObject(env.RELAY.getByName("git-sqlite-history-limits"), async (relay: Relay) => {72+ let repository = repo(relay, "history");73+ let parent: string | null = null;74+ for (let index = 0; index < 130; index++) {75+ const next = await commitObjects(`commit-${index}`, parent);76+ await repository.commit({ id: `commit-${index}`, updates: [{ name: "refs/heads/main", old: parent, new: next.commit }], pack: next.pack });77+ parent = next.commit;78+ }79+ expect((await repository.loadRefs()).sequence).toBe(130);80+ const objectCount = relay.sql.exec<{ count: number }>("SELECT COUNT(*) AS count FROM git_sqlite_objects WHERE repository=?", "history").one().count;81+ expect(objectCount).toBe(390);82+83+ repository = repo(relay, "history");84+ expect((await repository.loadRefs()).refs["refs/heads/main"]).toBe(parent);85+ expect(await repository.getObject(parent!)).not.toBeNull();86+ const before = relay.sql.exec<{ count: number }>("SELECT COUNT(*) AS count FROM git_sqlite_objects WHERE repository=?", "history").one().count;87+ await repository.commit({ id: "duplicate-payload", updates: [{ name: "refs/tags/repeated", old: null, new: parent }] });88+ expect(relay.sql.exec<{ count: number }>("SELECT COUNT(*) AS count FROM git_sqlite_objects WHERE repository=?", "history").one().count).toBe(before);89+ });90+ });91+92+ it("allows only one concurrent compare-and-set publication", async () => {93+ await runInDurableObject(env.RELAY.getByName("git-sqlite-cas"), async (relay: Relay) => {94+ const repository = repo(relay, "cas");95+ const seed = await commitObjects("seed", null);96+ await repository.commit({ id: "seed", updates: [{ name: "refs/heads/main", old: null, new: seed.commit }], pack: seed.pack });97+ const left = await commitObjects("left", seed.commit);98+ const right = await commitObjects("right", seed.commit);99+ const results = await Promise.allSettled([100+ repository.commit({ id: "left", updates: [{ name: "refs/heads/main", old: seed.commit, new: left.commit }], pack: left.pack }),101+ repository.commit({ id: "right", updates: [{ name: "refs/heads/main", old: seed.commit, new: right.commit }], pack: right.pack }),102+ ]);103+ expect(results.filter((result) => result.status === "fulfilled")).toHaveLength(1);104+ expect(results.filter((result) => result.status === "rejected" && /changed|Ref changed/.test(String(result.reason)))).toHaveLength(1);105+ expect((await repository.loadRefs()).sequence).toBe(2);106+ });107+ });108+109+ it("rolls back the complete publication when onCommit fails, while afterCommit runs after commit", async () => {110+ await runInDurableObject(env.RELAY.getByName("git-sqlite-hooks"), async (relay: Relay) => {111+ const failing = repo(relay, "hook-failure", { onCommit: () => { throw new Error("hook failed"); } });112+ const first = await commitObjects("hook", null);113+ await expect(failing.commit({ id: "hook", updates: [{ name: "refs/heads/main", old: null, new: first.commit }], pack: first.pack })).rejects.toThrow("hook failed");114+ expect((await failing.loadRefs()).sequence).toBe(0);115+ expect(failing.hasObjectSync(first.commit)).toBe(false);116+ expect(relay.sql.exec("SELECT 1 AS present FROM git_sqlite_receipts WHERE repository=?", "hook-failure").toArray()).toHaveLength(0);117+118+ let after = 0;119+ const succeeding = repo(relay, "after-hook", { afterCommit: () => { after++; throw new Error("after failed"); } });120+ const second = await commitObjects("after", null);121+ await expect(succeeding.commit({ id: "after", updates: [{ name: "refs/heads/main", old: null, new: second.commit }], pack: second.pack })).rejects.toThrow("after failed");122+ expect(after).toBe(1);123+ expect((await succeeding.loadRefs()).sequence).toBe(1);124+ });125+ });126+127+ it("captures request data before asynchronous hashing", async () => {128+ await runInDurableObject(env.RELAY.getByName("git-sqlite-request-capture"), async (relay: Relay) => {129+ const repository = repo(relay, "capture");130+ const next = await commitObjects("captured", null);131+ const request = { id: "captured", updates: [{ name: "refs/heads/main", old: null, new: next.commit }], pack: next.pack };132+ const pending = repository.commit(request);133+ request.id = "mutated";134+ request.updates[0].name = "refs/heads/mutated";135+ request.pack.fill(0);136+ await pending;137+ expect((await repository.loadRefs()).refs["refs/heads/main"]).toBe(next.commit);138+ expect(await repository.lookupRecord("captured")).not.toBeNull();139+ });140+ });141+142+ it("deduplicates an identical object pack on a later ref publication", async () => {143+ await runInDurableObject(env.RELAY.getByName("git-sqlite-dedup-pack"), async (relay: Relay) => {144+ const repository = repo(relay, "dedup");145+ const next = await commitObjects("dedup", null);146+ await repository.commit({ id: "first", updates: [{ name: "refs/heads/main", old: null, new: next.commit }], pack: next.pack });147+ 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();148+ await repository.commit({ id: "second", updates: [{ name: "refs/tags/repeated", old: null, new: next.commit }], pack: next.pack });149+ 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();150+ expect(after).toEqual(before);151+ expect((await repository.loadRefs()).refs["refs/tags/repeated"]).toBe(next.commit);152+ });153+ });154+155+ it("runs authority checks again for an identical retry after access is revoked", async () => {156+ await runInDurableObject(env.RELAY.getByName("git-sqlite-retry-authority"), async (relay: Relay) => {157+ const repository = repo(relay, "retry-authority");158+ const next = await commitObjects("retry", null);159+ const request = { id: "retry", updates: [{ name: "refs/heads/main", old: null, new: next.commit }], pack: next.pack };160+ await repository.commit(request);161+ let revoked = false;162+ await expect(repository.commit(request, () => { if (revoked) throw new Error("revoked"); })).resolves.toEqual({ id: "retry", sequence: 1, replayed: true });163+ revoked = true;164+ await expect(repository.commit(request, () => { if (revoked) throw new Error("revoked"); })).rejects.toThrow("revoked");165+ expect((await repository.loadRefs()).sequence).toBe(1);166+ });167+ });168+169+ it("reports the actual rows read and written by SQL cursors", async () => {170+ await runInDurableObject(env.RELAY.getByName("git-sqlite-metering"), async (relay: Relay) => {171+ relay.sql.exec(GIT_SQLITE_SCHEMA);172+ let observedRead = 0;173+ let observedWritten = 0;174+ const sql = {175+ exec<T extends Record<string, SqlStorageValue>>(query: string, ...bindings: SqlStorageValue[]) {176+ const cursor = relay.sql.exec<T>(query, ...bindings);177+ const toArray = cursor.toArray.bind(cursor);178+ return {179+ toArray() { const rows = toArray(); observedRead += cursor.rowsRead; observedWritten += cursor.rowsWritten; return rows; },180+ get rowsRead() { return cursor.rowsRead; },181+ get rowsWritten() { return cursor.rowsWritten; },182+ };183+ },184+ } as unknown as SqlStorage;185+ let meteredRead = 0;186+ let meteredWritten = 0;187+ const repository = new GitSqliteRepository({ sql, repository: "metering", transactionSync: (closure) => relay.storage.transactionSync(closure), meter: (event) => { meteredRead += event.rowsRead; meteredWritten += event.rowsWritten; } });188+ const next = await commitObjects("meter", null);189+ await repository.commit({ id: "meter", updates: [{ name: "refs/heads/main", old: null, new: next.commit }], pack: next.pack });190+ await repository.loadRefs();191+ expect(meteredRead).toBe(observedRead);192+ expect(meteredWritten).toBe(observedWritten);193+ expect(meteredWritten).toBeGreaterThan(0);194+ });195+ });196+197+ it("enforces cumulative raw and object quotas across individually valid pushes", async () => {198+ await runInDurableObject(env.RELAY.getByName("git-sqlite-cumulative-limits"), async (relay: Relay) => {199+ const first = await commitObjects("first", null);200+ const second = await commitObjects("second", first.commit);201+ const rawLimited = repo(relay, "raw-cumulative", { limits: { maxRawBytes: first.rawBytes + 1 } });202+ await rawLimited.commit({ id: "first", updates: [{ name: "refs/heads/main", old: null, new: first.commit }], pack: first.pack });203+ await expect(rawLimited.commit({ id: "second", updates: [{ name: "refs/heads/main", old: first.commit, new: second.commit }], pack: second.pack })).rejects.toThrow();204+ const objectLimited = repo(relay, "object-cumulative", { limits: { maxObjects: 3 } });205+ await objectLimited.commit({ id: "first", updates: [{ name: "refs/heads/main", old: null, new: first.commit }], pack: first.pack });206+ await expect(objectLimited.commit({ id: "second", updates: [{ name: "refs/heads/main", old: first.commit, new: second.commit }], pack: second.pack })).rejects.toThrow();207+ });208+ });209+210+ it("rejects dangling and wrong-type graph links before publication", async () => {211+ await runInDurableObject(env.RELAY.getByName("git-sqlite-graph-validation"), async (relay: Relay) => {212+ for (const [name, wrongType] of [["dangling", false], ["wrong-type", true]] as const) {213+ const repository = repo(relay, name);214+ const malformed = await malformedGraph(wrongType);215+ await expect(repository.commit({ id: name, updates: [{ name: "refs/heads/main", old: null, new: malformed.commit }], pack: malformed.pack })).rejects.toThrow();216+ expect((await repository.loadRefs()).sequence).toBe(0);217+ }218+ });219+ });220+221+ it("enforces raw, compressed, object, pack, and metadata budgets", async () => {222+ await runInDurableObject(env.RELAY.getByName("git-sqlite-budget-limits"), async (relay: Relay) => {223+ const next = await commitObjects("budget", null, new Uint8Array(1024));224+ const cases: Array<[string, Partial<import("../../src/git-sqlite.ts").GitSqliteLimits>]> = [225+ ["raw", { maxTransactionRawBytes: 100 }],226+ ["compressed", { maxCompressedBytes: 1 }],227+ ["objects", { maxObjects: 2 }],228+ ["pack", { maxPackBytes: next.pack.length - 1 }],229+ ["metadata", { maxMetadataBytes: 1 }],230+ ];231+ for (const [name, limits] of cases) {232+ const repository = repo(relay, `budget-${name}`, { limits });233+ await expect(repository.commit({ id: name, updates: [{ name: "refs/heads/main", old: null, new: next.commit }], pack: next.pack })).rejects.toThrow();234+ expect((await repository.loadRefs()).sequence).toBe(0);235+ }236+ });237+ });238+239+ it("round trips a near-4 MiB incompressible blob through multiple chunks", async () => {240+ await runInDurableObject(env.RELAY.getByName("git-sqlite-large-object"), async (relay: Relay) => {241+ const blob = new Uint8Array(4 * 1024 * 1024);242+ for (let offset = 0; offset < blob.length; offset += 65536) crypto.getRandomValues(blob.subarray(offset, offset + 65536));243+ const next = await commitObjects("large", null, blob);244+ 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 } });245+ await repository.commit({ id: "large", updates: [{ name: "refs/heads/main", old: null, new: next.commit }], pack: next.pack });246+ const stored = await repository.getObject(await objectId("blob", blob));247+ expect(stored?.data).toEqual(blob);248+ expect(relay.sql.exec<{ chunks: number }>("SELECT chunks FROM git_sqlite_objects WHERE repository=? AND oid=?", "large", stored!.oid).one().chunks).toBeGreaterThan(1);249+ });250+ });251+});@@ -0,0 +1,97 @@
1+import { env, runInDurableObject } from "cloudflare:test";2+import { describe, expect, it } from "vitest";3+import { encodePack, type GitObject } from "ntig";4+import { GitSqliteRepository } from "../../src/git-sqlite.ts";5+import { GIT_SQLITE_SCHEMA } from "../../src/git-sqlite.ts";6+import type { Relay } from "../../src/relay.ts";7+8+const 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+};15+16+async 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+}32+33+async 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+}38+39+function 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+}43+44+describe("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+ });57+58+ 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+ });71+72+ 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+ });82+83+ 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+});