OverviewFilesCommitsIssuesDocsReleasesPeers

pear-git-v3 / lib / services / push-index.js

115 lines · javascript ref:
/**
 * Push attribution index — "which repos has this user pushed to, and when".
 *
 * Powers the public profile's "Repos pushed" activity list. Recorded at the
 * authenticated git-receive-pack layer (where the pusher's canonical userId
 * and the repo are both known) — NOT at the post-receive/ref-update-observed
 * hook, which is an unauthenticated loopback call.
 *
 * DESIGN NOTE (accepted trade-off): this index is LOCAL per daemon. Because
 * `7rnx.net/git` is load-balanced across backends, a user's pushes split
 * across whichever backend received them, so a profile view reflects only the
 * pushes that landed on the serving backend. A future upgrade is email-match
 * commit attribution (`git log --author`) or a replicated index. Until then
 * this is deliberately simple and isolated — rebuildable, no schema on the
 * replicated log.
 *
 * Storage: <storage>/push-index.json
 *   { version, users: { <userId>: { <repo>: { firstPush, lastPush, count, lastSha } } } }
 */

import { readFile, writeFile, rename } from '#node:fs/promises'
import { join } from '#node:path'

const index = new Map() // userId -> Map<repo, { firstPush, lastPush, count, lastSha }>

let persistPath = null
let loaded = false
let dirty = false
let saveTimer = null

/**
 * Point the index at a storage dir and load persisted entries. Call once at
 * daemon boot before serving. Idempotent.
 */
export async function configurePushIndex (storageDir) {
  if (!storageDir) return
  persistPath = join(storageDir, 'push-index.json')
  if (loaded) return
  loaded = true
  try {
    const obj = JSON.parse(await readFile(persistPath, 'utf8'))
    if (obj && obj.users && typeof obj.users === 'object') {
      for (const [userId, repos] of Object.entries(obj.users)) {
        if (!repos || typeof repos !== 'object') continue
        const m = new Map()
        for (const [repo, rec] of Object.entries(repos)) {
          if (rec && typeof rec.count === 'number') m.set(repo, rec)
        }
        if (m.size) index.set(userId, m)
      }
    }
  } catch {
    // No file yet (first boot) or unreadable — start empty.
  }
}

/**
 * Record that `userId` pushed to `repo`. Best-effort; a falsy userId (an
 * unattributable push) is ignored. `now` is injectable for tests.
 */
export function recordPush (userId, repo, sha = null, now = Date.now()) {
  if (!userId || !repo) return
  let repos = index.get(userId)
  if (!repos) { repos = new Map(); index.set(userId, repos) }
  const prev = repos.get(repo)
  repos.set(repo, {
    firstPush: prev ? prev.firstPush : now,
    lastPush: now,
    count: (prev ? prev.count : 0) + 1,
    lastSha: sha || (prev ? prev.lastSha : null)
  })
  dirty = true
  schedulePersist()
}

/**
 * Repos `userId` has pushed to, most-recent first.
 * @returns {Array<{ repo, firstPush, lastPush, count, lastSha }>}
 */
export function listUserPushes (userId) {
  const repos = index.get(userId)
  if (!repos) return []
  return [...repos.entries()]
    .map(([repo, rec]) => ({ repo, ...rec }))
    .sort((a, b) => b.lastPush - a.lastPush)
}

/** Force any pending persist immediately (graceful shutdown + tests). */
export async function flushPushIndex () {
  if (saveTimer) { clearTimeout(saveTimer); saveTimer = null }
  await persistNow()
}

function schedulePersist () {
  if (!persistPath || saveTimer) return
  saveTimer = setTimeout(() => { saveTimer = null; persistNow().catch(() => {}) }, 1000)
  if (saveTimer && typeof saveTimer.unref === 'function') saveTimer.unref()
}

async function persistNow () {
  if (!persistPath || !dirty) return
  dirty = false
  const users = {}
  for (const [userId, repos] of index) {
    users[userId] = Object.fromEntries(repos)
  }
  const tmp = persistPath + '.tmp'
  try {
    await writeFile(tmp, JSON.stringify({ version: 1, users }))
    await rename(tmp, persistPath)
  } catch {
    dirty = true // best-effort; retry on next change
  }
}