176 lines · 8.2 KB
import { all, chunks, first, placeholders, run } from "./db";
import { accountDb, repoDb, stmt } from "./scoped-db";
import { forgetLocator } from "./scoped-db/locators";
import { forEachScope } from "./account-scope";
import { repoReadableSql } from "./repos";
import { invalidateUnread } from "./user-inbox";
import { env } from "cloudflare:workers";
import { addCounter } from "./counters";
import { REPO_STARS } from "./counter-keys";

/**
 * Stars, watches, thread subscriptions, assignments, review requests and notifications feed the dashboard and
 * inbox; rows held by users who can no longer read the repository must go when access is revoked
 * (collaborator removal, org membership removal, public -> private), otherwise private activity keeps
 * streaming to them. Stars and watches are D1 edges; subscriptions, assignments and review requests live in
 * each repository's RepoState, notifications in each recipient's AccountState.
 */
const CANNOT_READ = `NOT EXISTS (SELECT 1 FROM repos r WHERE r.id = t.repo_id AND ${repoReadableSql("r", "t.user_id")})
  AND NOT EXISTS (SELECT 1 FROM users u_ WHERE u_.account_id = t.user_id AND u_.is_admin = 1)`;

/** Of `userIds`, those who can no longer read `repoId` (a deleted repository is unreadable to everyone). */
async function unreadableBy(repoId: string, userIds: string[]): Promise<string[]> {
  const out: string[] = [];
  for (const ids of chunks([...new Set(userIds)])) {
    const rows = await all<{ user_id: string }>(
      `SELECT t.user_id FROM (SELECT a.id AS user_id, ? AS repo_id FROM accounts a WHERE a.id IN (${placeholders(ids.length)})) t
       WHERE ${CANNOT_READ}`,
      repoId,
      ...ids,
    );
    out.push(...rows.map((row) => row.user_id));
  }
  return out;
}

const REPO_EDGES = ["thread_subscriptions", "issue_assignees", "review_requests"] as const;

async function purgeRepoEdges(repoId: string, userId: string | null): Promise<void> {
  const db = repoDb(repoId);
  const userFilter = userId === null ? "" : " AND x.user_id = ?";