~ knotless Search/
↑↓ move↵ open⇧↵ all results

chadtmiller.com/pds.js

An atproto PDS in JavaScript, for Cloudflare Workers and Node

0
0
main
251 lines · 7.8 KB Raw History
/**
 * 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<string, unknown>} */ (value);
  const ref = blob.ref;
  if (typeof ref !== 'object' || ref === null) return false;
  return (
    typeof (/** @type {Record<string, unknown>} */ (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<string, unknown>} */ (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<string, unknown>} */ (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<string, unknown>} */ (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;
}