/** * Typed shape of the dev.pdsjs.git.repo record plus a runtime parser for * values coming back from getRecord (which are untyped JSON). */ /** * @typedef {Object} BlobObject * @property {string} [$type] * @property {{$link: string}} ref * @property {string} mimeType * @property {number} size */ /** * @typedef {Object} RefEntry * @property {string} name * @property {string} sha */ /** * @typedef {Object} BundleEntry * @property {string[]} heads * @property {BlobObject[]} parts * @property {BlobObject} [index] - the bundle's object index * (pack-index.js), for readers that fetch single objects with ranged * blob reads * @property {BlobObject[]} [indexParts] - the same index, split across blobs * for a repository whose index is larger than one blob. Read * indexPartsOf, which gives both forms as one list. */ /** * @typedef {Object} GitRepoRecord * @property {string} [$type] * @property {string} name * @property {string} [description] * @property {string} [defaultBranch] * @property {string} [upstream] - the repository record this copy was taken * from, where the copy is somebody else's repository * @property {RefEntry[]} refs * @property {BundleEntry[]} bundles * @property {string} createdAt * @property {string} [updatedAt] */ const SHA_RE = /^[0-9a-f]{40}([0-9a-f]{24})?$/; /** * A repository name is an address: it stands in a remote URL, a clone URL and * a page's path, so it takes the charset a record key takes. * @param {string} name * @returns {boolean} */ export function isValidRepoName(name) { return /^[A-Za-z0-9._~-]{1,512}$/.test(name) && name !== '.' && name !== '..'; } /** The record key an AT-URI ends with. */ export const rkeyOf = (/** @type {string} */ uri) => uri.split('/').pop() ?? ''; const TID_CHARS = '234567abcdefghijklmnopqrstuvwxyz'; const clockId = Math.floor(Math.random() * 1024); let lastTid = 0; /** * A record key for a new repository: an atproto TID, so keys sort by when * they were made and no key is a name. * * The remote helper ships as a CLI with no dependency on the server, so this * mints its own rather than reaching for core's. * @returns {string} */ export function createRepoKey() { let micros = Date.now() * 1000; if (micros <= lastTid) micros = lastTid + 1; lastTid = micros; let tid = ''; let left = micros; for (let i = 0; i < 11; i += 1) { tid = TID_CHARS[left & 31] + tid; left = Math.floor(left / 32); } return tid + TID_CHARS[(clockId >> 5) & 31] + TID_CHARS[clockId & 31]; } /** * What a repository record calls itself. The name is a required field, so the * key is a fallback for a record written against no lexicon at all. * @param {unknown} value * @param {string} rkey * @returns {string} */ export function repoNameOf(value, rkey) { const named = /** @type {{name?: unknown}} */ (value ?? {}).name; return typeof named === 'string' && named ? named : rkey; } /** * @param {unknown} value * @returns {value is BlobObject} */ function isBlobObject(value) { if (typeof value !== 'object' || value === null) return false; const blob = /** @type {Record} */ (value); const ref = blob.ref; if (typeof ref !== 'object' || ref === null) return false; return ( typeof (/** @type {Record} */ (ref).$link) === 'string' && typeof blob.mimeType === 'string' && typeof blob.size === 'number' ); } /** * Validate an untyped getRecord value into a GitRepoRecord. * @param {unknown} value * @returns {GitRepoRecord} */ export function parseRepoRecord(value) { if (typeof value !== 'object' || value === null) { throw new Error('git repo record is not an object'); } const rec = /** @type {Record} */ (value); if (typeof rec.name !== 'string' || typeof rec.createdAt !== 'string') { throw new Error('git repo record is missing name or createdAt'); } if (!Array.isArray(rec.refs) || !Array.isArray(rec.bundles)) { throw new Error('git repo record is missing refs or bundles'); } /** @type {RefEntry[]} */ const refs = []; for (const entry of rec.refs) { const ref = /** @type {Record} */ (entry); if ( typeof ref !== 'object' || ref === null || typeof ref.name !== 'string' || typeof ref.sha !== 'string' || !SHA_RE.test(ref.sha) ) { throw new Error('git repo record has a malformed ref entry'); } refs.push({ name: ref.name, sha: ref.sha }); } /** @type {BundleEntry[]} */ const bundles = []; for (const entry of rec.bundles) { const bundle = /** @type {Record} */ (entry); if ( typeof bundle !== 'object' || bundle === null || !Array.isArray(bundle.heads) || !Array.isArray(bundle.parts) || !bundle.heads.every((h) => typeof h === 'string') || !bundle.parts.every(isBlobObject) ) { throw new Error('git repo record has a malformed bundle entry'); } // A malformed index reads as absent, which downgrades the reader to // whole-chain download; the parts stay authoritative either way. const index = isBlobObject(bundle.index) ? /** @type {BlobObject} */ (bundle.index) : undefined; const indexParts = Array.isArray(bundle.indexParts) && bundle.indexParts.every(isBlobObject) ? /** @type {BlobObject[]} */ (bundle.indexParts) : undefined; bundles.push({ heads: /** @type {string[]} */ (bundle.heads), parts: /** @type {BlobObject[]} */ (bundle.parts), ...(index ? { index } : {}), ...(indexParts ? { indexParts } : {}), }); } return { name: rec.name, description: typeof rec.description === 'string' ? rec.description : undefined, defaultBranch: typeof rec.defaultBranch === 'string' ? rec.defaultBranch : undefined, upstream: typeof rec.upstream === 'string' ? rec.upstream : undefined, refs, bundles, createdAt: rec.createdAt, updatedAt: typeof rec.updatedAt === 'string' ? rec.updatedAt : undefined, }; } /** * A bundle's index blobs, in order, whichever form the record carries. * Concatenating them recovers the index file. An empty list means the * bundle has no index, and its reader downloads the whole chain. * @param {BundleEntry} bundle * @returns {BlobObject[]} */ export function indexPartsOf(bundle) { if (bundle.indexParts) return bundle.indexParts; return bundle.index ? [bundle.index] : []; } /** * The chain index where a compaction merge begins. The suffix from that * index is the shortest tail whose merge keeps the sizes geometric: every * kept bundle is at least `factor` times the total size after it. The last * index alone means nothing merges. A factor of 0 selects the last index. * @param {number[]} sizes - bundle byte sizes, oldest first * @param {number} factor * @returns {number} */ export function compactionStart(sizes, factor) { let start = sizes.length - 1; let sum = sizes[start] ?? 0; for (let i = start - 1; i >= 0; i--) { if (sizes[i] >= factor * sum) break; sum += sizes[i]; start = i; } return start; } /** * Split bundle bytes into blob-sized chunks. * @param {Uint8Array} bytes * @param {number} chunkSize * @returns {Uint8Array[]} */ export function splitIntoChunks(bytes, chunkSize) { if (chunkSize <= 0) throw new Error('chunk size must be positive'); /** @type {Uint8Array[]} */ const chunks = []; for (let offset = 0; offset < bytes.length; offset += chunkSize) { chunks.push(bytes.subarray(offset, offset + chunkSize)); } return chunks.length > 0 ? chunks : [bytes]; } /** * Reassemble a bundle from its downloaded chunks. * @param {Uint8Array[]} chunks * @returns {Uint8Array} */ export function concatChunks(chunks) { const total = chunks.reduce((sum, c) => sum + c.length, 0); const out = new Uint8Array(total); let offset = 0; for (const chunk of chunks) { out.set(chunk, offset); offset += chunk.length; } return out; }