clone

nostr relays on demand at <name>.bind.ws: claim one with your key, pay in zaps past the free allowance

dependabot/github_actions/actions-9b61906d8b
bindws / src / git-sqlite.ts
git-sqlite.ts 27.0K
1import {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";12import { decodePack, objectLinks, type GitObject, type GitObjectType } from "ntig";13import { sha256, validateRefName } from "ntig";14import { sha1 } from "@noble/hashes/legacy.js";15import { bytesToHex } from "@noble/hashes/utils.js";16import { Unzlib, zlibSync } from "fflate";17import { DEFAULT_GIT_LIMITS, MAX_GIT_LIMITS } from "./git-limits.ts";1819// The object store keeps one compressed, content-addressed row per Git object.20// Chunks keep every SQLite value below the production BLOB limit, while refs21// and retry receipts remain in the same transaction as publication.22export const GIT_SQLITE_SCHEMA = `23CREATE TABLE IF NOT EXISTS git_sqlite_objects (24 repository TEXT NOT NULL,25 oid TEXT NOT NULL,26 type TEXT NOT NULL,27 raw_size INTEGER NOT NULL,28 compressed_size INTEGER NOT NULL,29 chunks INTEGER NOT NULL,30 PRIMARY KEY (repository, oid)31);32CREATE TABLE IF NOT EXISTS git_sqlite_object_chunks (33 repository TEXT NOT NULL,34 oid TEXT NOT NULL,35 chunk INTEGER NOT NULL,36 data BLOB NOT NULL,37 PRIMARY KEY (repository, oid, chunk),38 FOREIGN KEY (repository, oid) REFERENCES git_sqlite_objects(repository, oid) ON DELETE CASCADE39);40CREATE TABLE IF NOT EXISTS git_sqlite_object_links (41 repository TEXT NOT NULL,42 oid TEXT NOT NULL,43 position INTEGER NOT NULL,44 link_oid TEXT NOT NULL,45 link_type TEXT NOT NULL,46 PRIMARY KEY (repository, oid, position)47);48CREATE TABLE IF NOT EXISTS git_sqlite_refs (49 repository TEXT NOT NULL,50 name TEXT NOT NULL,51 oid TEXT NOT NULL,52 PRIMARY KEY (repository, name)53);54CREATE TABLE IF NOT EXISTS git_sqlite_receipts (55 repository TEXT NOT NULL,56 id TEXT NOT NULL,57 sequence INTEGER NOT NULL,58 request_hash TEXT NOT NULL,59 record_hash TEXT NOT NULL,60 updates TEXT NOT NULL,61 pack_hash TEXT,62 PRIMARY KEY (repository, id)63);64CREATE TABLE IF NOT EXISTS git_sqlite_meta (65 repository TEXT PRIMARY KEY,66 sequence INTEGER NOT NULL,67 tip TEXT,68 compressed_bytes INTEGER NOT NULL DEFAULT 0,69 raw_bytes INTEGER NOT NULL DEFAULT 0,70 object_count INTEGER NOT NULL DEFAULT 0,71 metadata_bytes INTEGER NOT NULL DEFAULT 072);73CREATE INDEX IF NOT EXISTS git_sqlite_receipts_sequence ON git_sqlite_receipts(repository, sequence);74`;7576// Storage safety is separate from tenant intake policy. Lowering77// maxObjectBytes must not make previously accepted objects unreadable.78export const GIT_SQLITE_PHYSICAL_OBJECT_BYTES = 64 * 1024 * 1024;7980export interface GitSqliteLimits {81 /** Maximum bytes accepted in one incoming pack request. */82 maxPackBytes: number;83 /** Maximum uncompressed size of one retained Git object and one decode. */84 maxObjectBytes: number;85 /** Maximum retained objects in this repository. */86 maxObjects: number;87 /** Maximum retained uncompressed object bytes in this repository. */88 maxRawBytes: number;89 /** Maximum uncompressed bytes decoded in one commit operation. */90 maxTransactionRawBytes: number;91 /** Maximum objects decoded in one commit operation. */92 maxTransactionObjects: number;93 /** Maximum retained compressed object bytes in this repository. */94 maxCompressedBytes: number;95 /** Maximum retained index, refs and receipt metadata bytes. */96 maxMetadataBytes: number;97 maxRefs: number;98 maxGraphEdges: number;99 chunkBytes: number;100}101102export const DEFAULT_GIT_SQLITE_LIMITS: Readonly<GitSqliteLimits> = Object.freeze({103 // Repository retention is intentionally much larger than one transport104 // operation. Hosts may lower or raise these values through policy.105 maxPackBytes: DEFAULT_GIT_LIMITS.maxPackBytes,106 maxObjectBytes: DEFAULT_GIT_LIMITS.maxObjectBytes,107 maxObjects: DEFAULT_GIT_LIMITS.maxObjects,108 maxRawBytes: DEFAULT_GIT_LIMITS.maxRawBytes,109 maxTransactionRawBytes: DEFAULT_GIT_LIMITS.maxTransactionRawBytes,110 maxTransactionObjects: DEFAULT_GIT_LIMITS.maxTransactionObjects,111 maxCompressedBytes: DEFAULT_GIT_LIMITS.maxCompressedBytes,112 maxMetadataBytes: DEFAULT_GIT_LIMITS.maxMetadataBytes,113 maxRefs: DEFAULT_GIT_LIMITS.maxRefs,114 maxGraphEdges: DEFAULT_GIT_LIMITS.maxGraphEdges,115 chunkBytes: 512 * 1024,116});117118type Sql = SqlStorage;119type Bytes = ArrayBuffer;120const ID = /^[a-zA-Z0-9_.:-]{1,128}$/;121const OID = /^[0-9a-f]{40}$/;122const ZERO = "0".repeat(40);123124const asBuffer = (bytes: Uint8Array): Bytes => bytes.slice().buffer;125126export interface GitSqliteCommitChange {127 id: string;128 sequence: number;129 refs: Readonly<Record<string, string>>;130 objects: readonly { oid: string; type: GitObjectType; rawSize: number; compressedSize: number }[];131}132133export interface GitSqliteOptions {134 sql: Sql;135 repository?: string;136 limits?: Partial<GitSqliteLimits>;137 /** Copy incoming packs before awaiting so callers may safely reuse them. */138 capturePack?: boolean;139 transactionSync: <T>(closure: () => T) => T;140 meter?: (event: { rowsRead: number; rowsWritten: number; rawBytes: number; compressedBytes: number }) => void;141 quota?: (event: { rawBytes: number; compressedBytes: number; metadataBytes: number }) => void;142 onCommit?: (change: GitSqliteCommitChange) => void;143 afterCommit?: (change: GitSqliteCommitChange) => void;144}145146// GitSqliteRepository stores validated Git objects in one relay DO database.147// The class implements ntig's repository contract so the HTTP and GRASP148// adapters can use it once the object-reader seam is available.149export class GitSqliteRepository implements GitRepository {150 readonly prefix: string;151 readonly limits: Readonly<GitSqliteLimits>;152 readonly #sql: Sql;153 readonly #transaction: <T>(closure: () => T) => T;154 readonly #meter?: GitSqliteOptions["meter"];155 readonly #quota?: GitSqliteOptions["quota"];156 readonly #onCommit?: GitSqliteOptions["onCommit"];157 readonly #afterCommit?: GitSqliteOptions["afterCommit"];158 readonly #capturePack: boolean;159160 constructor(options: GitSqliteOptions) {161 this.#sql = options.sql;162 this.prefix = options.repository ?? "default";163 if (!/^[a-zA-Z0-9:_-]{1,256}$/.test(this.prefix)) throw new Error("Invalid repository name");164 this.limits = Object.freeze({ ...DEFAULT_GIT_SQLITE_LIMITS, ...options.limits });165 for (const value of Object.values(this.limits))166 if (!Number.isSafeInteger(value) || value < 1) throw new Error("Invalid Git SQLite limit");167 if (this.limits.chunkBytes > 512 * 1024) throw new Error("Git SQLite chunk limit exceeds platform bounds");168 if (!options.transactionSync) throw new Error("transactionSync is required for Git SQLite storage");169 this.#transaction = options.transactionSync;170 this.#meter = options.meter;171 this.#quota = options.quota;172 this.#onCommit = options.onCommit;173 this.#afterCommit = options.afterCommit;174 this.#capturePack = options.capturePack ?? true;175176 }177178 async withReadSession<T>(operation: (repository: GitSqliteRepository) => Promise<T>): Promise<T> {179 return operation(this);180 }181182 async loadRefs(): Promise<RefSnapshot> {183 const meta = this.#meta();184 const refs = this.#refs();185 return { sequence: meta.sequence, tip: meta.tip, version: String(meta.sequence), refs };186 }187188 async load(): Promise<Snapshot> {189 const refs = await this.loadRefs();190 const packs: Uint8Array[] = [];191 // Indexed callers use getObject/getObjectInfo and do not need a complete192 // repository pack. Keep the legacy pack view bounded by operation limits;193 // a large repository therefore remains readable instead of failing merely194 // because its retained history exceeds one request's decode budget.195 const meta = this.#meta();196 if (meta.rawBytes > this.limits.maxTransactionRawBytes || meta.objectCount > this.limits.maxTransactionObjects) return { ...refs, records: [], packs: [] };197 const legacy = this.#exec<{ maxRaw: number | null; raw: number; objects: number }>("SELECT MAX(raw_size) AS maxRaw, COALESCE(SUM(raw_size),0) AS raw, COUNT(*) AS objects FROM git_sqlite_objects WHERE repository=?", this.prefix).toArray()[0];198 if (!legacy || (legacy.maxRaw ?? 0) > 4 * 1024 * 1024 || legacy.raw > 16 * 1024 * 1024 || legacy.objects > 4096) return { ...refs, records: [], packs: [] };199 const ids = this.#exec<{ oid: string }>("SELECT oid FROM git_sqlite_objects WHERE repository=? ORDER BY oid LIMIT ?", this.prefix, this.limits.maxTransactionObjects + 1).toArray();200 if (ids.length > this.limits.maxTransactionObjects) return { ...refs, records: [], packs: [] };201 const objects: GitObject[] = [];202 for (const { oid } of ids) objects.push((await this.getObject(oid))!);203 if (objects.length) {204 const pack = await this.#pack(objects);205 if (!pack || pack.length > this.limits.maxPackBytes) return { ...refs, records: [], packs: [] };206 packs.push(pack);207 }208 return { ...refs, records: [], packs };209 }210211 async getObject(oid: string): Promise<GitObject | null> {212 if (!OID.test(oid)) throw new IntegrityError("Invalid object ID");213 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];214 if (!row) return null;215 const data = this.#read(oid, row.raw_size);216 if (bytesToHex(sha1.create().update(new TextEncoder().encode(`${row.type} ${data.length}\0`)).update(data).digest()) !== oid) throw new IntegrityError("Git object hash mismatch");217 return { oid, type: row.type, data };218 }219220 async getObjectInfo(oid: string): Promise<{ oid: string; type: GitObjectType; size: number; links: readonly { oid: string; type: GitObjectType }[] } | null> {221 if (!OID.test(oid)) throw new IntegrityError("Invalid object ID");222 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];223 if (!row) return null;224 // This is a read of retained data. A later policy may lower the intake225 // graph budget, but that must not strand objects accepted under an older226 // policy; use the physical safety bound for indexed reads.227 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, MAX_GIT_LIMITS.maxGraphEdges + 1).toArray().map((link) => ({ oid: link.link_oid, type: link.link_type }));228 if (!["blob", "tree", "commit", "tag"].includes(row.type) || !Number.isSafeInteger(row.raw_size) || row.raw_size < 0 || row.raw_size > GIT_SQLITE_PHYSICAL_OBJECT_BYTES || links.length > MAX_GIT_LIMITS.maxGraphEdges || links.some(link => !OID.test(link.oid) || !["blob", "tree", "commit", "tag"].includes(link.type))) throw new IntegrityError("Corrupt Git object metadata");229 return { oid, type: row.type, size: row.raw_size, links };230 }231232 async lookupRecord(id: string): Promise<WalRecord | null> {233 if (!ID.test(id)) throw new IntegrityError("Invalid request ID");234 const row = this.#exec<{ sequence: number; request_hash: string; updates: string; pack_hash: string | null }>(235 "SELECT sequence,request_hash,updates,pack_hash FROM git_sqlite_receipts WHERE repository=? AND id=?",236 this.prefix,237 id,238 ).toArray()[0];239 if (!row) return null;240 return {241 format: 1,242 sequence: row.sequence,243 parent: row.sequence > 1 ? this.#tipAt(row.sequence - 1) : null,244 id,245 requestHash: row.request_hash,246 pack: row.pack_hash,247 updates: JSON.parse(row.updates) as WalRecord["updates"],248 };249 }250251 hasObjectSync(oid: string): boolean {252 return OID.test(oid) && this.#exec("SELECT 1 AS present FROM git_sqlite_objects WHERE repository=? AND oid=?", this.prefix, oid).toArray().length > 0;253 }254255 async commit(request: CommitRequest, beforeCommit?: () => void): Promise<Receipt> {256 const id = request.id;257 if (!ID.test(id)) throw new IntegrityError("Invalid request ID");258 if (request.updates.length > this.limits.maxRefs) throw new LimitError("Too many ref updates");259 if (request.updates.length === 0) throw new IntegrityError("Missing ref updates");260 const updates = request.updates.map((update) => ({ ...update })).sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0);261 for (const update of updates) {262 validateRefName(update.name);263 if (update.old !== null && (!OID.test(update.old) || update.old === ZERO)) throw new IntegrityError("Invalid old ref");264 if (update.new !== null && (!OID.test(update.new) || update.new === ZERO)) throw new IntegrityError("Invalid new ref");265 }266 if (new Set(updates.map((update) => update.name)).size !== updates.length) throw new IntegrityError("Duplicate ref update");267 // HTTP/GRASP adapters normally own an immutable body buffer for the268 // duration of this call. They can opt out of this defensive copy to avoid269 // retaining a second large pack while decode is in progress. The default270 // remains copy-on-entry for direct callers and existing retry semantics.271 const pack = request.pack ? (this.#capturePack ? request.pack.slice() : request.pack) : undefined;272 if (pack && pack.length > this.limits.maxPackBytes) throw new LimitError("pack exceeds limit");273 const packHash = pack ? await sha256(pack) : null;274 const digest = await sha256(new TextEncoder().encode(JSON.stringify({ id, updates, pack: packHash })));275 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];276 if (prior) {277 if (prior.request_hash !== digest) throw new IntegrityError("Retry ID was reused with different data");278 this.#transaction(() => beforeCommit?.());279 return { id, sequence: prior.sequence, replayed: true };280 }281 const captured = this.#meta();282 const capturedRefs = this.#refs();283 const decoded = pack ? await decodePack(pack, {284 maxPackBytes: this.limits.maxPackBytes,285 maxObjectBytes: this.limits.maxObjectBytes,286 maxObjects: this.limits.maxTransactionObjects,287 maxTotalObjectBytes: this.limits.maxTransactionRawBytes,288 }, undefined, (oid) => this.getObject(oid)) : [];289 if (decoded.length > this.limits.maxObjects) throw new LimitError("Too many objects");290 const objects = new Map<string, GitObject>(decoded.map((object) => [object.oid, object]));291 for (const object of decoded) {292 const prior = await this.getObject(object.oid);293 if (prior && (prior.type !== object.type || !equal(prior.data, object.data))) throw new IntegrityError("Object ID collision");294 }295 const refs = { ...capturedRefs };296 for (const update of updates) {297 const current = refs[update.name] ?? null;298 if (current !== update.old) throw new ConflictError(`Ref changed: ${update.name}`);299 if (update.new === null) delete refs[update.name];300 else refs[update.name] = update.new;301 }302 if (Object.keys(refs).length > this.limits.maxRefs) throw new LimitError("Too many refs");303 const refNames = new Set(Object.keys(refs));304 for (const name of refNames) {305 const parts = name.split("/");306 for (let index = 2; index < parts.length; index++) if (refNames.has(parts.slice(0, index).join("/"))) throw new IntegrityError("Ref namespace collision");307 }308 await this.#validateGraph(objects, refs);309 const additions: GitObject[] = [];310 const seen = new Set<string>();311 for (const object of decoded) if (!seen.has(object.oid) && !this.hasObjectSync(object.oid)) { seen.add(object.oid); additions.push(object); }312 const stored = additions.map((object) => {313 const compressed = zlibSync(object.data);314 return { object, compressed, rawSize: object.data.length, compressedSize: compressed.length };315 });316 const rawBytes = stored.reduce((sum, item) => sum + item.rawSize, 0);317 const compressedBytes = stored.reduce((sum, item) => sum + item.compressedSize, 0);318 // Payload accounting includes repeated SQL keys and link/ref/receipt values.319 // The physical database meter separately includes pages and indexes.320 const utf8 = (value: string) => new TextEncoder().encode(value).length;321 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, this.limits.maxGraphEdges).length * (utf8(this.prefix) + 96), 0);322 const refMetadata = updates.reduce((sum, update) => sum + (update.new === null ? -1 : update.old === null ? 1 : 0) * (utf8(this.prefix) + utf8(update.name) + 40), 0);323 const metadataBytes = objectMetadata + refMetadata + utf8(this.prefix) + utf8(JSON.stringify(updates)) + utf8(id) + 160;324 if (rawBytes > this.limits.maxTransactionRawBytes) throw new LimitError("Transaction raw-byte limit exceeded");325 if (this.#meta().compressedBytes + compressedBytes > this.limits.maxCompressedBytes) throw new LimitError("Repository compressed-byte limit exceeded");326 if (this.#meta().metadataBytes + metadataBytes > this.limits.maxMetadataBytes) throw new LimitError("Repository metadata limit exceeded");327 const parent = captured.tip;328 const recordHash = await sha256(new TextEncoder().encode(JSON.stringify({ sequence: captured.sequence + 1, parent, id: id, digest })));329 const result = this.#transaction(() => this.#commitTransaction(id, updates, packHash, digest, recordHash, refs, capturedRefs, captured.sequence, captured.tip, stored, rawBytes, compressedBytes, metadataBytes, beforeCommit));330 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 })) });331 return result;332 }333334 #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 {335 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];336 if (prior) {337 if (prior.request_hash !== digest) throw new IntegrityError("Retry ID was reused with different data");338 beforeCommit?.();339 return { id, sequence: prior.sequence, replayed: true };340 }341 this.#exec("INSERT OR IGNORE INTO git_sqlite_meta(repository,sequence,tip) VALUES(?,0,NULL)", this.prefix);342 const meta = this.#meta();343 const sequence = meta.sequence + 1;344 if (meta.sequence !== capturedSequence || meta.tip !== capturedTip || JSON.stringify(this.#refs()) !== JSON.stringify(capturedRefs)) throw new ConflictError("Repository changed during Git preparation");345 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");346 const edgeCount = this.#exec<{ count: number }>("SELECT count(*) AS count FROM git_sqlite_object_links WHERE repository=?", this.prefix).toArray()[0].count;347 const addedEdges = stored.reduce((count, item) => count + objectLinks(item.object, Math.max(0, this.limits.maxGraphEdges - count)).length, 0);348 if (edgeCount + addedEdges > this.limits.maxGraphEdges) throw new LimitError("Repository graph edge limit exceeded");349 beforeCommit?.();350 const changeObjects: Array<GitSqliteCommitChange["objects"][number]> = [];351 for (const item of stored) {352 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));353 let chunk = 0;354 for (let position = 0; position < item.compressed.length; position += this.limits.chunkBytes)355 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)));356 for (const [position, link] of objectLinks(item.object, this.limits.maxGraphEdges).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);357 changeObjects.push({ oid: item.object.oid, type: item.object.type, rawSize: item.rawSize, compressedSize: item.compressedSize });358 }359 for (const update of updates) {360 if (update.new === null) this.#exec("DELETE FROM git_sqlite_refs WHERE repository=? AND name=?", this.prefix, update.name);361 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);362 }363 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);364 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);365 this.#quota?.({ rawBytes, compressedBytes, metadataBytes });366 this.#onCommit?.({ id, sequence, refs, objects: changeObjects });367 return { id, sequence, replayed: false };368 }369370 async #validateGraph(objects: ReadonlyMap<string, GitObject>, refs: Readonly<Record<string, string>>): Promise<void> {371 let edges = 0;372 for (const object of objects.values()) {373 const links = objectLinks(object, this.limits.maxGraphEdges - edges);374 edges += links.length;375 if (edges > this.limits.maxGraphEdges) throw new LimitError("Object graph edge limit exceeded");376 for (const link of links) {377 const target = objects.get(link.oid) ?? await this.getObjectInfo(link.oid);378 if (!target || target.type !== link.type) throw new IntegrityError(`Missing or wrong-type ${link.type}: ${link.oid}`);379 }380 }381 for (const [name, oid] of Object.entries(refs)) {382 validateRefName(name);383 const target = objects.get(oid) ?? await this.getObjectInfo(oid);384 if (!target) throw new IntegrityError(`Missing ref target: ${name}`);385 if (name.startsWith("refs/heads/") && target.type !== "commit") throw new IntegrityError("Branch target must be a commit");386 }387 }388389 #exec<T extends Record<string, SqlStorageValue> = Record<string, SqlStorageValue>>(query: string, ...bindings: SqlStorageValue[]) {390 const cursor = this.#sql.exec<T>(query, ...bindings);391 let rows: T[];392 try { rows = cursor.toArray(); }393 finally { this.#meter?.({ rowsRead: cursor.rowsRead, rowsWritten: cursor.rowsWritten, rawBytes: 0, compressedBytes: 0 }); }394 return { toArray: () => rows };395 }396397 #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 }; }398 #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])); }399 #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; }400 #read(oid: string, rawSize: number): Uint8Array {401 if (!Number.isSafeInteger(rawSize) || rawSize < 0 || rawSize > GIT_SQLITE_PHYSICAL_OBJECT_BYTES) throw new LimitError("Git object exceeds storage safety limit");402 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];403 const maxCompressed = GIT_SQLITE_PHYSICAL_OBJECT_BYTES + 65536;404 const maxChunks = Math.ceil(maxCompressed / this.limits.chunkBytes);405 if (!meta || !Number.isSafeInteger(meta.chunks) || meta.chunks < 1 || meta.chunks > maxChunks || !Number.isSafeInteger(meta.compressed_size) || meta.compressed_size < 1 || meta.compressed_size > maxCompressed) throw new IntegrityError("Corrupt Git object metadata");406407 // Feed SQLite chunks directly into the streaming zlib decoder. Keeping a408 // single chunk in flight avoids materializing compressed+raw copies of a409 // large object (which exceeds a Worker memory budget around 64 MiB).410 const data = new Uint8Array(rawSize);411 let written = 0;412 let compressedBytes = 0;413 let failed: unknown;414 const unzlib = new Unzlib((part) => {415 if (failed) return;416 if (written + part.length > data.length) {417 failed = new IntegrityError("Corrupt Git object: decompressed size exceeds metadata");418 return;419 }420 data.set(part, written);421 written += part.length;422 });423 try {424 for (let chunk = 0; chunk < meta.chunks; chunk++) {425 const row = this.#exec<{ chunk: number; data: ArrayBuffer }>("SELECT chunk,data FROM git_sqlite_object_chunks WHERE repository=? AND oid=? AND chunk=?", this.prefix, oid, chunk).toArray()[0];426 if (!row || row.chunk !== chunk || row.data.byteLength < 1 || row.data.byteLength > this.limits.chunkBytes) throw new IntegrityError("Corrupt Git object chunks");427 compressedBytes += row.data.byteLength;428 if (compressedBytes > meta.compressed_size) throw new IntegrityError("Corrupt Git object size");429 unzlib.push(new Uint8Array(row.data), chunk === meta.chunks - 1);430 }431 } catch (error) {432 failed = error;433 }434 if (failed) throw failed instanceof IntegrityError ? failed : new IntegrityError(`Corrupt Git object compression: ${failed instanceof Error ? failed.message : String(failed)}`);435 if (compressedBytes !== meta.compressed_size || written !== rawSize) throw new IntegrityError("Corrupt Git object size");436 return data;437 }438 async #pack(objects: readonly GitObject[]): Promise<Uint8Array | null> {439 // ntig's compatibility encoder has fixed conservative bounds. Indexed440 // callers handle larger retained objects; don't hydrate those objects441 // just to build a snapshot that cannot be encoded.442 if (objects.length > 4096 || objects.some((object) => object.data.length > 4 * 1024 * 1024) || objects.reduce((total, object) => total + object.data.length, 0) > 16 * 1024 * 1024) return null;443 const { encodePack } = await import("ntig");444 return encodePack(objects);445 }446}447448const equal = (a: Uint8Array, b: Uint8Array) => a.length === b.length && a.every((value, index) => value === b[index]);