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

chadtmiller.com/pds.js

An atproto PDS in JavaScript, for Cloudflare Workers and Node

0
0
main
98 lines · 3.6 KB Raw History
/**
 * Issue threads, read from the statements in every voice's own repo.
 *
 * An issue is a record in its filer's repo, a reply is a record naming its
 * parent, and the repository owner's disposition record is what a reader
 * shows as the issue's state. Nothing here is a mutable field on a shared
 * object: closed is a statement the owner signed, displayed as such, and the
 * filer's disagreement can sit beside it.
 *
 * These are the parts of that reading that touch no network.
 *
 * Workers-clean: no node imports.
 */

/**
 * The state each disposition names. A Map rather than an object, so a
 * statement carrying `constructor` reads as the comment it is.
 * @type {Map<string, 'open'|'resolved'|'declined'>}
 */
const STATE_OF = new Map([
  ['resolved', 'resolved'],
  ['declined', 'declined'],
  ['reopened', 'open'],
]);

/**
 * The state an issue thread displays, which is the repository owner's latest
 * word on it and nobody else's. Anyone may publish a disposition; anyone may
 * read it; only the owner's names the state, because the repository is what
 * the issue is about and the owner is who the repository answers to.
 *
 * The latest wins, so every word the owner says is reversible by saying
 * another one. `reopened` is how they get back to the state a thread starts
 * in, which no amount of `resolved` and `declined` can reach.
 *
 * The statement that names the state comes back with it, because when the
 * owner said it is a fact a row wants and only this read knows which of the
 * statements it was.
 * @param {Array<Record<string, *>>} thread - every statement in one thread
 * @param {string} ownerDid
 * @returns {{state: 'open'|'resolved'|'declined',
 *   said?: Record<string, *>}}
 */
export function issueState(thread, ownerDid) {
  let latest = null;
  for (const statement of thread) {
    if (statement.by?.did !== ownerDid) continue;
    if (!STATE_OF.has(statement.disposition)) continue;
    if (!latest || String(statement.createdAt) > String(latest.createdAt)) {
      latest = statement;
    }
  }
  if (!latest) return { state: 'open' };
  return { state: STATE_OF.get(latest.disposition) ?? 'open', said: latest };
}

/**
 * Group statements into threads: each opener with everything that answers
 * it, however deep the reply chain, ordered oldest first inside a thread and
 * newest activity first across threads.
 * @param {Array<Record<string, *>>} statements
 * @param {string} ownerDid
 * @returns {Array<{root: Record<string, *>, thread: Array<Record<string, *>>,
 *   state: ReturnType<typeof issueState>, latest: string}>}
 */
export function issueThreads(statements, ownerDid) {
  const byUri = new Map(statements.filter((s) => s.uri).map((s) => [s.uri, s]));
  /** @param {Record<string, *>} statement */
  const rootOf = (statement) => {
    let here = statement;
    for (let step = 0; step < 20; step++) {
      const parent = here.replyTo?.uri && byUri.get(here.replyTo.uri);
      if (!parent) return here;
      here = parent;
    }
    return here;
  };

  /** @type {Map<Record<string, *>, Array<Record<string, *>>>} */
  const groups = new Map();
  for (const statement of statements) {
    const root = rootOf(statement);
    groups.set(root, [...(groups.get(root) ?? []), statement]);
  }

  const threads = [...groups.entries()].map(([root, thread]) => {
    thread.sort((a, b) =>
      String(a.createdAt ?? '').localeCompare(String(b.createdAt ?? '')),
    );
    return {
      root,
      thread,
      state: issueState(thread, ownerDid),
      latest: String(thread[thread.length - 1]?.createdAt ?? ''),
    };
  });
  return threads.sort((a, b) => b.latest.localeCompare(a.latest));
}