clone

nostr relays on demand at <name>.bind.ws: claim one with your key, pay in zaps past the free allowance

dependabot/npm_and_yarn/npm-f01a459b0f
bindws / src / grasp.ts
grasp.ts 23.9K
1// GRASP-01 Smart HTTP on a relay origin. Nostr events authorize changes;2// Repositories publish compressed objects in SQLite. ntig supplies pack3// verification and the Git protocol.4import { createGitHandler, createAcceptedStateRepository, createPrRepository, parseRepositoryPath, repositoryAddress, repositoryStoragePrefix, LimitError, AuthorizationError, type CommitRequest, type GitRepository, type RefUpdate } from "ntig";5import { npubEncode, naddrEncode } from "nostr-tools/nip19";6import { now, tag, type Event } from "./event.ts";7import { KIND_REPO, KIND_REPO_STATE, KIND_GIT_PR, KIND_GIT_PR_UPDATE } from "./kinds.ts";8import { featureOn } from "./settings.ts";9import { page, escapeHTML as esc } from "./ui.ts";10import { relatedRepositoryCoordinates, repositoryCoordinate, type RepositoryAnnouncement } from "./grasp-policy.ts";11import { hostedAnnouncements, repository, repositoryState, requiredObjects, eventRepository, PR_REF_SECONDS } from "./grasp-state.ts";12import type { Relay } from "./relay.ts";13import { syncGitTick } from "./grasp-git-sync.ts";14import { graspSyncTick } from "./grasp-sync.ts";15import { GitSqliteRepository } from "./git-sqlite.ts";16import { sqliteGitBytes, gitSql } from "./git-catalog.ts";17import { gitHttpLimits, MAX_GIT_LIMITS } from "./git-limits.ts";18import { gitObjects } from "./git-objects.ts";1920const CORS = { "access-control-allow-origin": "*", "access-control-allow-methods": "GET, POST", "access-control-allow-headers": "Content-Type, Authorization, Git-Protocol, X-Git-Request-Id" };21export function graspCORS(response: Response, preflight = false): Response {22 const headers = new Headers(response.headers);23 for (const [key, value] of Object.entries(CORS)) headers.set(key, value);24 if (preflight) { void response.body?.cancel(); return new Response(null, { status: 204, headers }); }25 return new Response(response.body, { status: response.status, headers });26}27const answer = (reason: string, status: number) => new Response(reason, { status, headers: { ...CORS, "content-type": "text/plain; charset=utf-8", "cache-control": "no-store" } });28// isGitPath reserves malformed Git paths too, so every failure has CORS.29export const isGitPath = (url: URL) => /^\/(?:npub|prs\/)/.test(url.pathname) || /\.git(?:\/|$)/.test(url.pathname);3031export const gitStoragePrefix = async (relay: Relay, repo: RepositoryAnnouncement, alternativePRs = false) => `${relay.slug}/git/${await repositoryStoragePrefix(repositoryAddress(npubEncode(repo.owner), repo.identifier), alternativePRs)}`;3233export type GitBackend = GitSqliteRepository;3435// gitRepository binds one repository to the relay SQLite database.36export async function gitRepository(relay: Relay, repo: RepositoryAnnouncement, alternativePRs = false): Promise<GitBackend> {37 const prefix = await gitStoragePrefix(relay, repo, alternativePRs);38 const namespace = prefix.split("/").filter(Boolean).at(-1)!;39 let released: Event[] = [];40 const sql = new GitSqliteRepository({41 sql: relay.sql, repository: namespace,42 limits: relay.settings.policy.git,43 // Authority wrappers capture external input; sync builds its own packs.44 // The backend can borrow those immutable buffers for the commit duration.45 capturePack: false,46 transactionSync: work => relay.storage.transactionSync(work),47 meter: counts => relay.meterPush(counts.rowsRead, counts.rowsWritten),48 quota: () => {49 // This callback runs after staging SQL rows in the transaction. A quota50 // failure rolls back publication and its receipt together.51 const fuel = relay.fuel.status(now(), relay.eventBytes(), relay.mediaBytes());52 if (fuel.outOfFuel) throw new LimitError("Relay storage or fuel limit reached");53 const cap = relay.settings.limitsOf(repo.owner)?.cap ?? 0;54 if (cap && sqliteGitBytes(relay, repo.owner, namespace) + relay.store.authorBytes(repo.owner) > cap) throw new LimitError("Repository owner's storage cap reached");55 if (sqliteGitBytes(relay) > relay.settings.policy.git.maxRelayBytes) throw new LimitError("Relay Git storage quota reached");56 },57 onCommit: change => {58 gitSql(relay, "INSERT OR IGNORE INTO git_sqlite_catalog(repository,owner,identifier,alternative) VALUES(?,?,?,?)", namespace, repo.owner, repo.identifier, alternativePRs ? 1 : 0);59 const updates = JSON.parse(gitSql<{ updates: string }>(relay, "SELECT updates FROM git_sqlite_receipts WHERE repository=? AND id=?", namespace, change.id).one().updates) as RefUpdate[];60 trackPrRefs(relay, repo, updates, alternativePRs);61 released = releasePending(relay, repo, alternativePRs, oid => sql.hasObjectSync(oid));62 },63 afterCommit: () => { for (const event of released) relay.broadcast(event); released = []; },64 });65 return sql;66}6768// prTip distinguishes unknown IDs from known events that cannot authorize69// this repository. Purgatory PRs may authorize their own arriving objects.70function prTip(relay: Relay, repo: RepositoryAnnouncement, id: string): string | null | false {71 const row = gitSql<{ raw: string }>(relay, `SELECT raw FROM events WHERE id=?`, id).toArray()[0];72 if (!row) {73 const timed = gitSql<{ until: number }>(relay, `SELECT until FROM grasp_pr_refs WHERE repo=? AND ref=?`, repositoryCoordinate(repo), `refs/nostr/${id}`).toArray()[0];74 return timed && timed.until <= now() ? false : null;75 }76 const e = JSON.parse(row.raw) as Event;77 if (![KIND_GIT_PR, KIND_GIT_PR_UPDATE].includes(e.kind) || relay.settings.isEventHidden(id) || relay.settings.isEventBanned(id) || relay.settings.isBanned(e.pubkey)) return false;78 const exp = Number(tag(e, "expiration"));79 if (exp && exp <= now()) return false;80 if (eventRepository(relay, e)?.id !== repo.id) return false;81 return /^[0-9a-f]{40}$/.test(tag(e, "c")) ? tag(e, "c") : false;82}8384export const prCoordinate = (repo: Pick<RepositoryAnnouncement, "owner" | "identifier">) => `pr:${repo.owner}:${repo.identifier}`;85export const prRepositoryIdentity = (owner: string, identifier: string): RepositoryAnnouncement => ({ id: `pr:${owner}:${identifier}`, owner, identifier, clone: [], relays: [], maintainers: [], private: false });8687// alternativePrAddress matches an accepted event's own signer path on this relay.88export function alternativePrAddress(relay: Relay, e: Event, host = ""): RepositoryAnnouncement | null {89 const hosts = new Set([`${relay.slug}.${relay.domain}`, ...(host ? [host] : []), ...relay.settings.policy.customHosts.filter(h => !h.site && h.status === "active").map(h => h.host)]);90 for (const raw of e.tags.filter(t => t[0] === "clone").flatMap(t => t.slice(1))) {91 try {92 const url = new URL(raw);93 if (!hosts.has(url.host) || url.origin !== relay.webURL(url.host) || url.username || url.password || url.search || url.hash) continue;94 const path = parseRepositoryPath(url.pathname);95 if (!path || !path.alternativePRs || path.endpoint !== "root" || path.address.pubkey !== e.pubkey) continue;96 if (!relatedRepositoryCoordinates(e).some(c => c.split(":").slice(2).join(":") === path.address.identifier)) continue;97 return prRepositoryIdentity(e.pubkey, path.address.identifier);98 } catch { /* Another clone tag may name this service. */ }99 }100 return null;101}102103// alternativePrTip retains unknown upload deadlines independently of normal repos.104function alternativePrTip(relay: Relay, repo: RepositoryAnnouncement, id: string, host = ""): string | null | false {105 const row = gitSql<{ raw: string }>(relay, `SELECT raw FROM events WHERE id=?`, id).toArray()[0];106 if (!row) {107 const timed = gitSql<{ until: number }>(relay, `SELECT until FROM grasp_pr_refs WHERE repo=? AND ref=?`, prCoordinate(repo), `refs/nostr/${id}`).toArray()[0];108 return timed && timed.until <= now() ? false : null;109 }110 const e = JSON.parse(row.raw) as Event;111 if (![KIND_GIT_PR, KIND_GIT_PR_UPDATE].includes(e.kind) || e.pubkey !== repo.owner || relay.settings.isEventHidden(id) || relay.settings.isEventBanned(id) || relay.settings.isBanned(e.pubkey)) return false;112 const expires = Number(tag(e, "expiration"));113 const pending = gitSql<{ until: number }>(relay, `SELECT until FROM grasp_pending WHERE id=?`, id).toArray()[0];114 if ((expires && expires <= now()) || (pending && pending.until <= now())) return false;115 if (alternativePrAddress(relay, e, host)?.id !== repo.id) return false;116 return /^(?!0{40}$)[0-9a-f]{40}$/.test(tag(e, "c")) ? tag(e, "c") : false;117}118119// alternativePrRepository keeps transport policy in ntig and visibility in the relay.120function alternativePrRepository(relay: Relay, repo: RepositoryAnnouncement, wal: GitBackend, host: string): GitRepository {121 const tracked: GitRepository = {122 load: () => wal.load(), loadRefs: () => wal.loadRefs(), lookupRecord: id => wal.lookupRecord(id),123 ...objectMethods(wal),124 commit: async request => {125 return wal.commit(request, () => checkAuthority(relay, repo, request.updates, true, host));126 },127 };128 const authorized = createPrRepository(tracked, { lookupTip: async id => alternativePrTip(relay, repo, id, host), allowUnknownPrRefs: true });129 return {130 load: () => authorized.load(),131 ...objectMethods(authorized),132 lookupRecord: id => authorized.lookupRecord!(id),133 loadRefs: () => authorized.loadRefs!(),134 commit: async request => {135 const receipt = await authorized.commit(request);136 await promote(relay, repo, wal, true);137 return receipt;138 },139 };140}141142// objectMethods preserves the indexed path through policy wrappers.143const objectMethods = (repo: GitRepository): Pick<GitRepository, "getObject" | "getObjectInfo"> => repo.getObject && repo.getObjectInfo ? { getObject: oid => repo.getObject!(oid), getObjectInfo: oid => repo.getObjectInfo!(oid) } : {};144145// checkAuthority runs again inside SQLite publication after asynchronous decoding.146function checkAuthority(relay: Relay, repo: RepositoryAnnouncement, updates: readonly RefUpdate[], alternative = false, host = "") {147 if (!featureOn(relay.settings.policy, "grasp") || (alternative && !featureOn(relay.settings.policy, "grasp06")) || relay.settings.policy.reads !== "open" || relay.settings.isUnclaimed() || relay.settings.leaseExpired(now()) || relay.settings.isBanned(repo.owner)) throw new AuthorizationError("Repository hosting is unavailable");148 if (!alternative && repository(relay, repo.owner, repo.identifier)?.id !== repo.id) throw new AuthorizationError("Repository announcement changed");149 const state = alternative ? null : repositoryState(relay, repo);150 for (const update of updates) {151 if (/^refs\/nostr\/[0-9a-f]{64}$/.test(update.name)) {152 const tip = alternative ? alternativePrTip(relay, repo, update.name.slice(11), host) : prTip(relay, repo, update.name.slice(11));153 if (update.new === null || tip === false || (tip !== null && tip !== update.new)) throw new AuthorizationError("Pull request authority changed");154 } else if (alternative || !state || (state.refs[update.name] ?? null) !== update.new) throw new AuthorizationError("Repository state changed");155 }156}157158// trackPrRefs retains the deadline for an upload whose signed PR arrives later.159function trackPrRefs(relay: Relay, repo: RepositoryAnnouncement, updates: readonly RefUpdate[], alternative = false, host = "") {160 const coordinate = alternative ? prCoordinate(repo) : repositoryCoordinate(repo);161 if (alternative) {162 const existing = gitSql<{ ref: string }>(relay, "SELECT ref FROM grasp_pr_refs WHERE repo=?", coordinate).toArray();163 const namespaces = gitSql<{ n: number }>(relay, "SELECT count(DISTINCT repo) AS n FROM grasp_pr_refs WHERE repo LIKE 'pr:%'").one().n;164 if (!existing.length && namespaces >= relay.settings.policy.git.maxRepositories) throw new LimitError("Alternative PR repository quota reached");165 if (new Set([...existing.map(row => row.ref), ...updates.filter(u => u.new !== null).map(u => u.name)]).size > relay.settings.policy.git.maxRefs) throw new LimitError("Alternative PR ref quota reached");166 }167 for (const update of updates) {168 if (!/^refs\/nostr\/[0-9a-f]{64}$/.test(update.name)) continue;169 if (update.new === null) gitSql(relay, "DELETE FROM grasp_pr_refs WHERE repo=? AND ref=?", coordinate, update.name);170 else {171 const tip = alternative ? alternativePrTip(relay, repo, update.name.slice(11), host) : prTip(relay, repo, update.name.slice(11));172 gitSql(relay, "INSERT INTO grasp_pr_refs(repo,ref,until) VALUES(?,?,?) ON CONFLICT(repo,ref) DO NOTHING", coordinate, update.name, typeof tip === "string" ? 0 : now() + PR_REF_SECONDS);173 }174 }175}176177const pendingEvents = (relay: Relay) => gitSql<{ raw: string }>(relay, "SELECT raw FROM events JOIN grasp_pending ON grasp_pending.id=events.id WHERE grasp_pending.until>? AND (events.expires=0 OR events.expires>?) LIMIT 1024", now(), now()).toArray().map(row => JSON.parse(row.raw) as Event);178const pendingObjects = (relay: Relay, repo: RepositoryAnnouncement, e: Event, alternative: boolean) => {179 if (relay.settings.isEventHidden(e.id) || relay.settings.isEventBanned(e.id) || relay.settings.isBanned(e.pubkey)) return null;180 const relevant = alternative ? alternativePrTip(relay, repo, e.id) === tag(e, "c") : e.kind === KIND_REPO ? repository(relay, repo.owner, repo.identifier)?.id === e.id : e.kind === KIND_REPO_STATE ? tag(e, "d") === repo.identifier && repositoryState(relay, repo)?.id === e.id : eventRepository(relay, e)?.id === repo.id;181 return relevant ? requiredObjects(relay, e) : null;182};183184// releasePending changes event visibility in the same transaction as refs.185function releasePending(relay: Relay, repo: RepositoryAnnouncement, alternative: boolean, hasObject: (oid: string) => boolean): Event[] {186 const released: Event[] = [];187 const present = new Map<string, boolean>();188 for (const event of pendingEvents(relay)) {189 const required = pendingObjects(relay, repo, event, alternative);190 if (!required || !required.every(oid => { if (!present.has(oid)) present.set(oid, hasObject(oid)); return present.get(oid); })) continue;191 if (event.kind === KIND_GIT_PR || event.kind === KIND_GIT_PR_UPDATE) gitSql(relay, "UPDATE grasp_pr_refs SET until=0 WHERE repo=? AND ref=?", alternative ? prCoordinate(repo) : repositoryCoordinate(repo), `refs/nostr/${event.id}`);192 gitSql(relay, "DELETE FROM grasp_pending WHERE id=?", event.id);193 released.push(event);194 }195 return released;196}197198// promote finishes event-first arrivals and retries, rechecking authority after reads.199export async function promote(relay: Relay, repo: RepositoryAnnouncement, wal: GitRepository, alternative = false) {200 const pending = pendingEvents(relay).flatMap(e => pendingObjects(relay, repo, e, alternative) ?? []);201 if (!pending.length || !(await (wal.loadRefs ? wal.loadRefs() : wal.load())).sequence) return;202 const objects = await gitObjects(wal);203 const available = new Set<string>();204 for (const oid of new Set(pending)) if (await objects.info(oid)) available.add(oid);205 const released = relay.storage.transactionSync(() => releasePending(relay, repo, alternative, oid => available.has(oid)));206 for (const event of released) relay.broadcast(event);207}208209// authorizedRepository applies signed event authority to stored Git refs.210export function authorizedRepository(relay: Relay, repo: RepositoryAnnouncement, wal: GitBackend): GitRepository {211 const checked: GitRepository = {212 load: () => wal.load(), loadRefs: () => wal.loadRefs(), lookupRecord: id => wal.lookupRecord(id),213 ...objectMethods(wal),214 commit: request => wal.commit(request, () => checkAuthority(relay, repo, request.updates)),215 };216 const authorized = createAcceptedStateRepository(checked, {217 maxRefs: MAX_GIT_LIMITS.maxRefs,218 lookupState: async () => { const state = repositoryState(relay, repo); return state ? { eventId: state.id, refs: state.refs, head: state.head } : null; },219 lookupPrTip: async (id) => prTip(relay, repo, id),220 allowUnknownPrRefs: true,221 });222 return {223 load: () => authorized.load(),224 ...objectMethods(authorized),225 lookupRecord: id => authorized.lookupRecord!(id),226 loadRefs: () => authorized.loadRefs ? authorized.loadRefs() : authorized.load(),227 commit: async (request: CommitRequest) => {228 const receipt = await authorized.commit(request);229 await promote(relay, repo, wal);230 return receipt;231 },232 };233}234235// grasp serves Git only while open reads, admission and fuel permit hosting.236// One in-flight operation bounds memory and fences all relay-side mutations.237export async function grasp(relay: Relay, req: Request, url: URL): Promise<Response> {238 if (req.method === "OPTIONS") return new Response(null, { status: 204, headers: CORS });239 if (!featureOn(relay.settings.policy, "grasp")) return answer("not found", 404);240 if (relay.settings.policy.reads !== "open") return answer("restricted: public Git hosting requires open reads", 403);241 if (relay.settings.isUnclaimed() || relay.settings.leaseExpired(now())) return answer("restricted: relay is not active", 403);242 const parsed = parseRepositoryPath(url.pathname);243 if (!parsed || (parsed.alternativePRs && !featureOn(relay.settings.policy, "grasp06"))) return answer("not found", 404);244 const repo = parsed.alternativePRs ? prRepositoryIdentity(parsed.address.pubkey, parsed.address.identifier) : repository(relay, parsed.address.pubkey, parsed.address.identifier);245 if (!repo) return answer("not found", 404);246 const limited = relay.ipLimit(req.headers.get("x-relay-ip") || "unknown", req.method === "POST" ? "events" : "reqs");247 if (limited) return answer(limited, 429);248 if (relay.fuelStatus().outOfFuel) return answer("restricted: relay storage or fuel limit reached", 403);249 const access = relay.repositoryAccess;250 const run = req.method === "POST" && url.pathname.endsWith("/git-upload-pack")251 ? access.response.bind(access) : access.run.bind(access);252 return run("git", async () => {253 try {254 if (parsed.endpoint === "root") {255 if (req.method !== "GET" && req.method !== "HEAD") return answer("invalid: method not allowed", 405);256 const coordinate = naddrEncode({ kind: KIND_REPO, pubkey: repo.owner, identifier: repo.identifier });257 const description = parsed.alternativePRs ? "Alternative pull request storage follows the signer's accepted PR events. Only refs/nostr/&lt;event-id&gt; are served." : "Git storage follows signed Nostr repository state.";258 const link = parsed.alternativePRs ? "" : `<p><a href="nostr:${coordinate}">Open in a Nostr Git client</a></p>`;259 const html = page(repo.identifier, `<h1>${esc(repo.identifier)}</h1><p>${description}</p>${link}<pre>git clone ${esc(relay.webURL(url.host) + parsed.prefix)}</pre>`);260 return new Response(req.method === "HEAD" ? null : html, { headers: { ...CORS, "content-type": "text/html; charset=utf-8", "cache-control": "no-store" } });261 }262 const wal = await gitRepository(relay, repo, parsed.alternativePRs);263 const response = await wal.withReadSession(async (scopedWal) => {264 const handler = createGitHandler(parsed.alternativePRs ? alternativePrRepository(relay, repo, scopedWal, url.host) : authorizedRepository(relay, repo, scopedWal), { ...gitHttpLimits(relay.settings.policy.git), prefix: parsed.prefix, authorizePush: () => true, observe: (event) => relay.meterBytes(event.requestBytes, event.responseBytes) });265 return await handler(req);266 });267 const headers = new Headers(response.headers);268 for (const [key, value] of Object.entries(CORS)) headers.set(key, value);269 return new Response(response.body, { status: response.status, headers });270 } catch (error) {271 return answer(error instanceof LimitError ? "restricted: Git repository limit reached" : "error: Git repository unavailable", error instanceof LimitError ? 413 : 503);272 } finally {273 await relay.ensureAlarm(now() + 60);274 }275 }, () => answer("restricted: relay operation in progress; retry", 429));276}277278// graspTick expires purgatory and cleans one repository per alarm. Event279// deletion removes visibility; retained Git objects remain charged until teardown.280export async function graspTick(relay: Relay): Promise<number> {281 gitSql(relay, `DELETE FROM events WHERE id IN (SELECT id FROM grasp_pending WHERE until<=?)`, now());282 const all = hostedAnnouncements(relay);283 const last = gitSql<{ coordinate: string }>(relay, `SELECT coordinate FROM grasp_tick WHERE id=1`).toArray()[0]?.coordinate ?? "";284 const ordered = all.sort((a, b) => repositoryCoordinate(a).localeCompare(repositoryCoordinate(b)));285 const repo = ordered.find((r) => repositoryCoordinate(r) > last) ?? ordered[0];286 if (repo) gitSql(relay, `INSERT OR REPLACE INTO grasp_tick(id,coordinate) VALUES(1,?)`, repositoryCoordinate(repo));287 for (const { repo: coordinate } of gitSql<{ repo: string }>(relay, `SELECT DISTINCT repo FROM grasp_pr_refs`).toArray()) {288 if (!coordinate.startsWith("pr:") && !all.some((r) => repositoryCoordinate(r) === coordinate)) gitSql(relay, `DELETE FROM grasp_pr_refs WHERE repo=?`, coordinate);289 }290 if (repo && featureOn(relay.settings.policy, "grasp") && !relay.fuelStatus().outOfFuel) {291 try {292 const wal = await gitRepository(relay, repo);293 const snapshot = await wal.loadRefs();294 const refs = gitSql<{ ref: string }>(relay, `SELECT ref FROM grasp_pr_refs WHERE repo=? AND until>0 AND until<=?`, repositoryCoordinate(repo), now()).toArray();295 for (const { ref } of refs) {296 const current = snapshot.refs[ref];297 const tip = prTip(relay, repo, ref.slice("refs/nostr/".length));298 if (current && tip !== current) await wal.commit({ id: `expire:${ref.slice(-64)}:${current}`, updates: [{ name: ref, old: current, new: null }] });299 if (tip === current && current) gitSql(relay, `UPDATE grasp_pr_refs SET until=0 WHERE repo=? AND ref=?`, repositoryCoordinate(repo), ref);300 else gitSql(relay, `DELETE FROM grasp_pr_refs WHERE repo=? AND ref=?`, repositoryCoordinate(repo), ref);301 }302 await promote(relay, repo, wal);303 } catch { /* The next bounded alarm retries; retained bytes stay metered. */ }304 }305 if (featureOn(relay.settings.policy, "grasp06") && !relay.fuelStatus().outOfFuel) {306 const expired = gitSql<{ repo: string; ref: string }>(relay, `SELECT repo,ref FROM grasp_pr_refs WHERE repo LIKE 'pr:%' AND until>0 AND until<=? ORDER BY until,repo,ref LIMIT 1`, now()).toArray()[0];307 if (expired) {308 const match = /^pr:([0-9a-f]{64}):(.+)$/u.exec(expired.repo);309 if (match) {310 const pr = prRepositoryIdentity(match[1], match[2]);311 try {312 const wal = await gitRepository(relay, pr, true);313 const current = (await wal.loadRefs()).refs[expired.ref];314 const tip = alternativePrTip(relay, pr, expired.ref.slice(11));315 if (current && tip !== current) await wal.commit({ id: `expire:${expired.ref.slice(-64)}:${current}`, updates: [{ name: expired.ref, old: current, new: null }] });316 if (current && tip === current) gitSql(relay, `UPDATE grasp_pr_refs SET until=0 WHERE repo=? AND ref=?`, expired.repo, expired.ref);317 else gitSql(relay, `DELETE FROM grasp_pr_refs WHERE repo=? AND ref=?`, expired.repo, expired.ref);318 await promote(relay, pr, wal, true);319 } catch { /* Expired uploads stay hidden when cleanup cannot commit. */ }320 }321 }322 // Event-first and Git-first pairs are promoted even without a later push.323 const pending = gitSql<{ raw: string }>(relay, `SELECT raw FROM events JOIN grasp_pending ON grasp_pending.id=events.id WHERE kind IN (?,?) AND grasp_pending.until>? ORDER BY grasp_pending.until LIMIT 16`, KIND_GIT_PR, KIND_GIT_PR_UPDATE, now()).toArray();324 for (const row of pending) {325 const e = JSON.parse(row.raw) as Event;326 const parsed = alternativePrAddress(relay, e);327 if (!parsed) continue;328 try { await promote(relay, parsed, await gitRepository(relay, parsed, true), true); } catch { /* A later alarm retries. */ }329 break;330 }331 }332 const syncAt = await syncGitTick(relay);333 const eventsAt = await graspSyncTick(relay);334 const earliest = gitSql<{ n: number | null }>(relay, `SELECT min(at) AS n FROM (SELECT until AS at FROM grasp_pending UNION ALL SELECT until AS at FROM grasp_pr_refs WHERE until>0)`).one().n;335 const next = Math.min(earliest === null ? Infinity : now() + 60, syncAt || Infinity, eventsAt || Infinity);336 return Number.isFinite(next) ? next : 0;337}