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

chadtmiller.com/pds.js

An atproto PDS in JavaScript, for Cloudflare Workers and Node

0
0
main
188 lines · 5.0 KB Raw History
/**
 * Reading and updating the dev.pdsjs.oci.repo record value.
 */

import { OCI_REPO_COLLECTION } from './lexicon.js';

/**
 * @typedef {Object} BlobRef
 * @property {'blob'} $type
 * @property {{$link: string}} ref
 * @property {string} mimeType
 * @property {number} size
 */

/**
 * @typedef {Object} TagEntry
 * @property {string} tag
 * @property {string} digest
 */

/**
 * @typedef {Object} ManifestEntry
 * @property {string} digest
 * @property {string} mediaType
 * @property {BlobRef} blob
 */

/**
 * @typedef {Object} LayerEntry
 * @property {string} digest
 * @property {BlobRef} blob
 */

/**
 * @typedef {Object} OciRepoRecord
 * @property {string} name
 * @property {TagEntry[]} tags
 * @property {ManifestEntry[]} manifests
 * @property {LayerEntry[]} layers
 * @property {string} createdAt
 * @property {string} [updatedAt]
 */

/**
 * @param {string} cid
 * @param {string} mimeType
 * @param {number} size
 * @returns {BlobRef}
 */
export function blobRef(cid, mimeType, size) {
  return { $type: 'blob', ref: { $link: cid }, mimeType, size };
}

/**
 * A blob reference read back over XRPC carries `$link`; one decoded from a
 * CAR carries raw CID bytes. Only the `$link` shape is expected here, since
 * every read goes through getRecord, but a missing ref still parses to null
 * rather than throwing.
 * @param {unknown} value
 * @returns {BlobRef|null}
 */
function asBlobRef(value) {
  if (!value || typeof value !== 'object') return null;
  const ref =
    /** @type {{ref?: {$link?: unknown}, mimeType?: unknown, size?: unknown}} */ (
      value
    );
  if (typeof ref.ref?.$link !== 'string') return null;
  return {
    $type: 'blob',
    ref: { $link: ref.ref.$link },
    mimeType:
      typeof ref.mimeType === 'string'
        ? ref.mimeType
        : 'application/octet-stream',
    size: typeof ref.size === 'number' ? ref.size : 0,
  };
}

/**
 * @param {unknown} value - record value from getRecord
 * @returns {OciRepoRecord|null}
 */
export function parseRepoRecord(value) {
  if (!value || typeof value !== 'object') return null;
  const record = /** @type {Record<string, unknown>} */ (value);
  if (typeof record.name !== 'string') return null;

  /** @type {TagEntry[]} */
  const tags = [];
  for (const entry of Array.isArray(record.tags) ? record.tags : []) {
    if (
      entry &&
      typeof entry.tag === 'string' &&
      typeof entry.digest === 'string'
    ) {
      tags.push({ tag: entry.tag, digest: entry.digest });
    }
  }

  /** @type {ManifestEntry[]} */
  const manifests = [];
  for (const entry of Array.isArray(record.manifests) ? record.manifests : []) {
    const blob = asBlobRef(entry?.blob);
    if (entry && typeof entry.digest === 'string' && blob) {
      manifests.push({
        digest: entry.digest,
        mediaType:
          typeof entry.mediaType === 'string'
            ? entry.mediaType
            : 'application/octet-stream',
        blob,
      });
    }
  }

  /** @type {LayerEntry[]} */
  const layers = [];
  for (const entry of Array.isArray(record.layers) ? record.layers : []) {
    const blob = asBlobRef(entry?.blob);
    if (entry && typeof entry.digest === 'string' && blob) {
      layers.push({ digest: entry.digest, blob });
    }
  }

  return {
    name: record.name,
    tags,
    manifests,
    layers,
    createdAt:
      typeof record.createdAt === 'string'
        ? record.createdAt
        : new Date(0).toISOString(),
    updatedAt:
      typeof record.updatedAt === 'string' ? record.updatedAt : undefined,
  };
}

/**
 * The record value to store, with `$type` set and empty updatedAt dropped.
 * @param {OciRepoRecord} record
 * @returns {Record<string, unknown>}
 */
export function serializeRepoRecord(record) {
  return {
    $type: OCI_REPO_COLLECTION,
    name: record.name,
    tags: record.tags,
    manifests: record.manifests,
    layers: record.layers,
    createdAt: record.createdAt,
    ...(record.updatedAt ? { updatedAt: record.updatedAt } : {}),
  };
}

/**
 * Every blob CID the record references, for the link table.
 * @param {OciRepoRecord} record
 * @returns {string[]}
 */
export function recordBlobCids(record) {
  const cids = new Set();
  for (const entry of record.manifests) cids.add(entry.blob.ref.$link);
  for (const entry of record.layers) cids.add(entry.blob.ref.$link);
  return [...cids];
}

/**
 * Drop the named manifest and every tag naming it, then drop layers no
 * remaining manifest references. Layer pruning is by digest presence in the
 * remaining manifests' stored reference lists, which the record does not
 * hold; the caller passes the still-referenced digests it computed from the
 * remaining manifest bodies.
 * @param {OciRepoRecord} record
 * @param {string} digest
 * @param {Set<string>} stillReferenced - layer digests the remaining
 *   manifests reference
 * @returns {OciRepoRecord}
 */
export function removeManifest(record, digest, stillReferenced) {
  return {
    ...record,
    tags: record.tags.filter((t) => t.digest !== digest),
    manifests: record.manifests.filter((m) => m.digest !== digest),
    layers: record.layers.filter((l) => stillReferenced.has(l.digest)),
  };
}