/**
* 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
}
}