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

chadtmiller.com/pds.js

An atproto PDS in JavaScript, for Cloudflare Workers and Node

0
0
main
154 lines · 6.2 KB Raw History
// Blob listing and facet SQL shared by the SQLite-backed storage adapters
// (better-sqlite3 and Durable Object SQLite speak the same dialect). The
// schema is each adapter's own; these builders only read `blobs` and
// `record_blobs`.

/** @typedef {import('./ports.js').BlobListOptions} BlobListOptions */

// The four named media kinds; anything else buckets as `other`.
const NAMED_KINDS = ['image', 'video', 'audio', 'text'];

const KIND_CASE = `CASE
  WHEN mime_type LIKE 'image/%' THEN 'image'
  WHEN mime_type LIKE 'video/%' THEN 'video'
  WHEN mime_type LIKE 'audio/%' THEN 'audio'
  WHEN mime_type LIKE 'text/%' THEN 'text'
  ELSE 'other' END`;

/**
 * The collection-NSID authority (`app.bsky.feed.post` -> `app.bsky`) of each
 * blob's referencing records, one row per distinct blob/authority pair, so a
 * blob two apps reference counts once in each and repeat references within an
 * app count once. Derived by string position: a record URI is always
 * `at://<did>/<collection>/<rkey>`, and an NSID's authority is its first two
 * dot-segments.
 */
const BLOB_AUTHORITIES = `SELECT DISTINCT blob_cid, CASE
    WHEN instr(coll, '.') = 0 THEN coll
    WHEN instr(substr(coll, instr(coll, '.') + 1), '.') = 0 THEN coll
    ELSE substr(coll, 1, instr(coll, '.') + instr(substr(coll, instr(coll, '.') + 1), '.') - 1)
  END AS authority
  FROM (
    SELECT blob_cid, substr(c1, 1, instr(c1, '/') - 1) AS coll FROM (
      SELECT blob_cid, substr(substr(record_uri, 6), instr(substr(record_uri, 6), '/') + 1) AS c1
      FROM record_blobs
    )
  )`;

export const BLOB_FACETS_TOTAL_SQL =
  'SELECT COUNT(*) AS count, COALESCE(SUM(size), 0) AS bytes FROM blobs';

export const BLOB_FACETS_UNUSED_SQL = `SELECT COUNT(*) AS count, COALESCE(SUM(size), 0) AS bytes FROM blobs b
  WHERE NOT EXISTS (SELECT 1 FROM record_blobs rb WHERE rb.blob_cid = b.cid)`;

export const BLOB_FACETS_KINDS_SQL = `SELECT ${KIND_CASE} AS kind, COUNT(*) AS count, COALESCE(SUM(size), 0) AS bytes
  FROM blobs GROUP BY kind ORDER BY bytes DESC`;

export const BLOB_FACETS_APPS_SQL = `SELECT authority, COUNT(*) AS count, COALESCE(SUM(size), 0) AS bytes
  FROM (${BLOB_AUTHORITIES}) x JOIN blobs b ON b.cid = x.blob_cid
  GROUP BY authority ORDER BY bytes DESC`;

// A record minted by a normal write carries a TID rkey: 13 base32-sortable
// chars encoding (creation micros << 10 | clock id). The rkey is the URI's
// last path segment, so a TID rkey is exactly the URI's last 13 chars.
const TID_CHARS = '234567abcdefghijklmnopqrstuvwxyz';
// Shape check as instr() per char, not GLOB: Durable Object SQLite rejects a
// 13-class GLOB pattern as "too complex", though better-sqlite3 accepts it.
const TID_SHAPE = [
  `substr(rb.record_uri, -14, 1) = '/'`,
  ...Array.from(
    { length: 13 },
    (_, i) => `instr('${TID_CHARS}', substr(rb.record_uri, ${i - 13}, 1)) > 0`,
  ),
].join(' AND ');
const TID_MS = `(${Array.from(
  { length: 13 },
  (_, i) =>
    `(instr('${TID_CHARS}', substr(rb.record_uri, ${i - 13}, 1)) - 1) * ${(32n ** BigInt(12 - i)).toString()}`,
).join(' + ')}) / 1024000`;

/**
 * When a blob's content entered the account, as epoch ms: the earliest
 * referencing record's own stored timestamp, else that record's TID time,
 * else when the blob was stored here. The distinction matters for migrated
 * repos, where every blob's stored-here time is the migration date but the
 * records still carry the original timestamps.
 */
const LINK_TIME = `COALESCE(rb.record_time, CASE WHEN ${TID_SHAPE} THEN ${TID_MS} END)`;
const BLOB_TIME = `COALESCE(MIN(${LINK_TIME}), b.created_at)`;
const BLOB_TIME_KNOWN = `(MIN(${LINK_TIME}) IS NOT NULL)`;

/**
 * The filtered, ordered blob listing with each blob's referencing record URIs
 * concatenated (comma-separated; neither DIDs, NSIDs, nor rkeys may contain a
 * comma). Keyset pagination: the cursor is `<sortValue>:<cid>`, matching the
 * chosen order, so a page boundary holds still while blobs come and go.
 * @param {string|null} cursor
 * @param {number} limit
 * @param {BlobListOptions} [options]
 * @returns {{sql: string, params: (string|number)[]}}
 */
export function buildBlobListQuery(cursor, limit, options = {}) {
  const { kind, appAuthority, unused, sort } = options;
  const conds = [];
  /** @type {(string|number)[]} */
  const params = [];

  if (kind && NAMED_KINDS.includes(kind)) {
    conds.push('b.mime_type LIKE ?');
    params.push(`${kind}/%`);
  } else if (kind === 'other') {
    conds.push(
      `(b.mime_type IS NULL OR (${NAMED_KINDS.map(() => 'b.mime_type NOT LIKE ?').join(' AND ')}))`,
    );
    params.push(...NAMED_KINDS.map((named) => `${named}/%`));
  }

  if (appAuthority) {
    conds.push(
      'EXISTS (SELECT 1 FROM record_blobs rb2 WHERE rb2.blob_cid = b.cid AND rb2.record_uri LIKE ?)',
    );
    params.push(`at://%/${appAuthority}.%`);
  }

  if (unused) {
    conds.push(
      'NOT EXISTS (SELECT 1 FROM record_blobs rb2 WHERE rb2.blob_cid = b.cid)',
    );
  }

  // The sort key is an aggregate for `newest`, so the cursor bound lives in
  // HAVING (evaluated after grouping) rather than WHERE.
  const having = [];
  if (cursor) {
    const split = cursor.indexOf(':');
    const key = Number(cursor.slice(0, split));
    const cid = cursor.slice(split + 1);
    having.push('(sort_key < ? OR (sort_key = ? AND b.cid < ?))');
    params.push(key, key, cid);
  }

  const keyExpr = sort === 'largest' ? 'b.size' : BLOB_TIME;
  const where = conds.length ? `WHERE ${conds.join(' AND ')}` : '';
  params.push(limit);
  return {
    sql: `SELECT b.cid, b.mime_type, b.size, b.created_at, GROUP_CONCAT(rb.record_uri) AS uris, ${BLOB_TIME} AS time_ms, ${BLOB_TIME_KNOWN} AS time_known, ${keyExpr} AS sort_key
      FROM blobs b LEFT JOIN record_blobs rb ON b.cid = rb.blob_cid
      ${where}
      GROUP BY b.cid ${having.length ? `HAVING ${having.join(' AND ')}` : ''}
      ORDER BY sort_key DESC, b.cid DESC LIMIT ?`,
    params,
  };
}

/**
 * The cursor naming the last blob of a page, under the same options the page
 * was listed with.
 * @param {import('./ports.js').BlobDetails} blob
 * @param {BlobListOptions} [options]
 * @returns {string}
 */
export function blobListCursor(blob, options = {}) {
  const key = options.sort === 'largest' ? blob.size : blob.time;
  return `${key}:${blob.cid}`;
}