/**
* Issue-author index — "recent issues this user has posted, across any repo
* or org". Powers the public profile "Recent issues" list.
*
* WHY an index (not a live query): issues live in one autobase PER repo/org
* (lazy-loaded). Aggregating "issues by author" across the fleet would mean
* opening ~every tracker autobase on each profile view — the same cold-start
* cost the /repos page just shed. Instead we record (userId → issue ref) at
* create time (where the canonical author userId is already resolved), so the
* profile read is O(1). Like the push-index, this captures issues created
* AFTER deploy; a backfill (scan trackers once, offline) can seed history.
*
* Storage: <storage>/issue-index.json
* { version, users: { <userId>: { "<scope>:<name>:<id>": {scope,name,id,title,status,createdAt} } } }
*/
import { readFile, writeFile, rename } from '#node:fs/promises'
import { join } from '#node:path'
const index = new Map() // userId -> Map<key, entry>
let persistPath = null
let loaded = false
let dirty = false
let saveTimer = null
const keyOf = (e) => `${e.scope}:${e.name}:${e.id}`
/**
* Drop ALL in-memory state so the next configureIssueIndex() re-reads the
* index file — i.e. model a daemon restart within one process.
*
* Needed because a test cannot get a fresh copy of this module under Bare:
* `import('./issue-index.js?fresh=x')` returns a DISTINCT module under Node
* but the SAME object under Bare, which resolves by realpath and ignores the
* query. Without this, every test in a file shares one index and entries
* recorded by an earlier test are still counted by a later one.
*
* Test seam only — nothing in the daemon calls it. Deliberately does NOT touch
* the index FILE, only process-local state.
*/
export function resetIssueIndexForTests () {
index.clear()
persistPath = null
loaded = false
dirty = false
if (saveTimer) {
clearTimeout(saveTimer)
saveTimer = null
}
}
/** Point the index at a storage dir and load persisted entries. Idempotent. */
export async function configureIssueIndex (storageDir) {
if (!storageDir) return
persistPath = join(storageDir, 'issue-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, entries] of Object.entries(obj.users)) {
if (!entries || typeof entries !== 'object') continue
const m = new Map()
for (const [k, e] of Object.entries(entries)) {
if (e && e.name && (e.id || e.id === 0)) m.set(k, e)
}
if (m.size) index.set(userId, m)
}
}
} catch {
// No file yet (first boot) or unreadable — start empty.
}
}
/**
* Record an issue authored by `userId`. Best-effort; a falsy userId (an
* unattributable create) is ignored. Upserts by (scope,name,id) so a
* re-record refreshes title/status without duplicating.
* @param {string} userId
* @param {{scope:'repo'|'org', name:string, id:number, title?:string, status?:string, createdAt?:number}} entry
*/
export function recordIssue (userId, entry, now = Date.now()) {
if (!userId || !entry || !entry.name || (entry.id === undefined || entry.id === null)) return
let entries = index.get(userId)
if (!entries) { entries = new Map(); index.set(userId, entries) }
const e = {
scope: entry.scope === 'org' ? 'org' : 'repo',
name: entry.name,
id: entry.id,
title: entry.title || '',
status: entry.status || 'open',
createdAt: entry.createdAt || now
}
entries.set(keyOf(e), e)
dirty = true
schedulePersist()
}
/**
* Issues authored by `userId`, most-recent first.
* @returns {Array<{scope,name,id,title,status,createdAt}>}
*/
export function listUserIssues (userId, limit = 10) {
const entries = index.get(userId)
if (!entries) return []
return [...entries.values()]
.sort((a, b) => b.createdAt - a.createdAt)
.slice(0, limit)
}
/**
* One-time backfill from existing issue history. Enumerates EVERY tracker via
* issueRegistry.keys() (repos + orgs — the persisted index, not just the
* in-memory/lazy set), opens each, and records every issue's author. Upserts,
* so it's safe to run over an already-partially-populated index. Best-effort
* per tracker (a tracker that fails to open is skipped, not fatal).
*
* @returns {Promise<{indexed:number, repos:number, orgs:number}>}
*/
export async function backfillIssueIndex (svc) {
if (!svc || !svc.issueRegistry || typeof svc.issueRegistry.keys !== 'function') {
return { indexed: 0, repos: 0, orgs: 0 }
}
const allKeys = await svc.issueRegistry.keys().catch(() => ({ repos: {}, orgs: {} }))
const repos = Object.keys((allKeys && allKeys.repos) || {})
const orgs = Object.keys((allKeys && allKeys.orgs) || {})
let indexed = 0
const drain = async (scope, name, tracker) => {
const list = await tracker.list({ status: 'all' }).catch(() => [])
for (const it of list) {
// The materialized view record's author field is `author` (the op's
// `userId` value is projected to `author`); keep `userId` as a fallback.
const authorId = it && (it.author || it.userId)
if (!authorId || it.id == null) continue
recordIssue(authorId, {
scope,
name,
id: it.id,
title: it.title,
status: it.status,
createdAt: it.createdAt || it.timestamp
}, it.createdAt || it.timestamp || undefined)
indexed++
}
}
for (const name of repos) {
try { await drain('repo', name, await svc.issueRegistry.getForRepo(name)) } catch {}
}
for (const name of orgs) {
try { await drain('org', name, await svc.issueRegistry.getForOrg(name)) } catch {}
}
await flushIssueIndex()
return { indexed, repos: repos.length, orgs: orgs.length }
}
/** Force any pending persist immediately (graceful shutdown + tests). */
export async function flushIssueIndex () {
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, entries] of index) users[userId] = Object.fromEntries(entries)
const tmp = persistPath + '.tmp'
try {
await writeFile(tmp, JSON.stringify({ version: 1, users }))
await rename(tmp, persistPath)
} catch {
dirty = true
}
}