/**
* Replicated last-activity signal for a repo — the ordering key for /repos.
*
* ## Why not the filesystem
*
* The landing page used to order on hyper-sync's `lastUpdated`, the newest
* mtime among HEAD, packed-refs and refs/** on the LOCAL disk. That is a
* property of the machine, not of the repo, so the two backends behind
* `7rnx.net/git` could not agree on the front page's order by construction.
* Measured 2026-09-05: identical 135-repo sets on both, and all 135 with a
* different `lastUpdated` — 67 apart by more than an hour, 61 by more than a
* day. Three separate ways it drifts:
*
* 1. CLONE TIME. A repo nobody has pushed to since import carries the time
* that host first fetched it. One bulk-import cohort reads 2026-06-03 on
* tabertron and 2026-04-21 on stortron.
* 2. STALENESS. `sync.repos` is a cache, and a mirror's pulls write inside
* an existing `<repo>.git` where the non-recursive repos-dir watcher
* cannot see them. See services/sync.js `attachRepoRefresh`.
* 3. THE DAEMON ITSELF. The 5-minute reconcileAll sweep force-writes refs
* from the tracker view onto disk. Materializing a ref bumps its mtime,
* so a repo nothing happened to jumps to the top — ifsrenderer-linux went
* from rank 20 to rank 1 on stortron with refs/heads/linux-port stamped
* at the exact second of the sweep.
*
* ## What replaces it
*
* The per-repo tracker's ref-view already carries a `timestamp` per ref
* (`listRefs()` -> `{ ref, hash, updatedBy, timestamp }`), stamped by the
* writer that ACCEPTED the push and replicated verbatim. Every peer holding
* the tracker derives the same number, so every backend renders the same
* order. It also keeps the property the mtime was chosen for: force-pushing
* an old commit is recent ACTIVITY, and unlike a git committer date it is
* stamped by the daemon rather than supplied by the committer.
*
* ## Where the values come from
*
* RefReconciler calls `listRefs()` on every pass anyway — event-driven off the
* tracker's `update`, plus the sweep — so recording `max(timestamp)` costs no
* extra autobase work. The manager wires the hook; nothing here talks to an
* autobase.
*
* Coverage is a boot walk: pear-git.js `ensureFor`s every `*.git` in the repos
* dir at startup and `RefReconciler.start()` reconciles immediately, so every
* repo with a mounted tracker has a value shortly after boot. A repo with no
* tracker never gets one and orders on its mtime as before.
*
* Storage: <storage>/repo-ref-activity.json
* { version: 1, repos: { <name>: <unix ms> } }
*
* Persisted for one reason: without it the first render after every restart
* falls back to mtimes — the exact disagreement this fixes — because the boot
* walk has not finished before the first visitor arrives. Losing the file is
* harmless; the next reconcile pass rewrites every entry.
*/
import { readFile, writeFile, rename } from '#node:fs/promises'
import { join } from '#node:path'
const activity = new Map() // repo name -> unix ms
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.
*
* @param {string} storageDir - daemon storage dir (opts.storage)
*/
export async function configureRefActivity (storageDir) {
if (!storageDir) return
persistPath = join(storageDir, 'repo-ref-activity.json')
if (loaded) return
loaded = true
try {
const obj = JSON.parse(await readFile(persistPath, 'utf8'))
if (obj && obj.repos && typeof obj.repos === 'object') {
for (const [name, ms] of Object.entries(obj.repos)) {
if (typeof ms === 'number' && ms > 0) activity.set(name, ms)
}
}
} catch {
// No file yet (first boot), unreadable, or corrupt. Starting empty costs
// one boot's worth of mtime ordering, not correctness.
}
}
/**
* Record the newest ref timestamp seen for a repo.
*
* MONOTONIC. A later pass can legitimately see an OLDER max than an earlier
* one — deleting the most recently pushed ref drops that entry out of the
* view — and letting the value fall back would re-shuffle the page for a repo
* whose activity plainly did not move backwards in time.
*
* @param {string} name - repo name
* @param {number} ms - unix ms, the max `timestamp` across the repo's refs
* @returns {boolean} whether the stored value advanced
*/
export function recordRefActivity (name, ms) {
if (!name || typeof ms !== 'number' || !(ms > 0)) return false
const prev = activity.get(name)
if (typeof prev === 'number' && prev >= ms) return false
activity.set(name, ms)
dirty = true
schedulePersist()
return true
}
/**
* @param {string} name - repo name
* @returns {number|null} unix ms, or null when this daemon has no value
*/
export function getRefActivity (name) {
if (!name) return null
const ms = activity.get(name)
return typeof ms === 'number' ? ms : null
}
/**
* Annotate repo records with `lastRefUpdate` where one is known.
*
* A repo with no value is returned UNCHANGED rather than carrying an explicit
* null, so `repoRecency()` can tell "no replicated signal, use the mtime"
* apart from "replicated signal of zero".
*
* @param {Array<object>} repos - records from sync.status()
* @returns {Array<object>}
*/
export function mergeRefActivity (repos) {
if (!Array.isArray(repos)) return []
return repos.map((r) => {
const ms = r && r.name ? getRefActivity(r.name) : null
return ms === null ? r : { ...r, lastRefUpdate: ms }
})
}
/** Force any pending persist immediately (graceful shutdown + tests). */
export async function flushRefActivity () {
if (saveTimer) { clearTimeout(saveTimer); saveTimer = null }
await persistNow()
}
/**
* Drop all process-local state so configureRefActivity() re-reads the file —
* i.e. model a daemon restart within one process. Test seam only; it does NOT
* touch the file. (A test cannot get a fresh copy of a module under Bare,
* which resolves by realpath and ignores an import query — same reason
* repo-activity.js carries resetRepoActivityCacheForTests.)
*/
export function resetRefActivityForTests () {
activity.clear()
persistPath = null
loaded = false
dirty = false
if (saveTimer) { clearTimeout(saveTimer); saveTimer = null }
}
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 repos = Object.fromEntries(activity)
const tmp = persistPath + '.tmp'
try {
await writeFile(tmp, JSON.stringify({ version: 1, repos }))
await rename(tmp, persistPath)
} catch {
dirty = true // best-effort; retry on the next change
}
}