import axios from "axios"; import { createHash } from "crypto"; import { getPool } from "../db/index.js"; import { getConfig } from "../common/config.js"; import { firstNonBlankLine } from "./firstNonBlankLine.js"; // Delay (ms) before the next attempt, given how many have already failed. // Exponential (startingDelay * factor^priorFailures), capped at maxDelay. function backoffMs(priorFailures, { startingDelayMs, maxDelayMs, factor }) { return Math.min(maxDelayMs, startingDelayMs * Math.pow(factor, priorFailures)); } // Best-effort remote agreement fetch. A failure is logged, never thrown. Local // URLs and already-cached agreements are skipped. Failures are tracked in // agreement_fetch with exponential backoff so an invalid URL isn't re-fetched on // every referencing create; after maxRetries we stop trying. export async function cacheAgreementMarkdown({ userId = null, agreementUuid, uri }, _client) { const config = getConfig(); if (typeof uri !== "string" || !uri || !agreementUuid) return; if (uri.startsWith(config.publicCoreUrl)) return; const client = _client || await getPool(); try { const cached = await client.query( `SELECT 1 FROM agreement_content WHERE agreement_uuid = $1 AND markdown IS NOT NULL`, [agreementUuid], ); if (cached.rowCount > 0) return; const { maxRetries } = config.agreementCache; const prior = await client.query( `SELECT attempts, next_attempt_ts FROM agreement_fetch WHERE agreement_uuid = $1`, [agreementUuid], ); const attempts = prior.rows[0]?.attempts ?? 0; if (attempts >= maxRetries) return; // gave up if (prior.rowCount > 0 && new Date(prior.rows[0].next_attempt_ts) > new Date()) return; // not due yet try { const res = await axios.get(uri, { responseType: "text" }); const markdown = String(res.data ?? "").trim(); if (!markdown) throw new Error("empty response"); const title = firstNonBlankLine(markdown); const hash = createHash("sha256").update(markdown).digest("hex"); // Content is immutable once inserted — INSERT only, never overwrite. await client.query( `INSERT INTO agreement_content (user_id, title, markdown, hash, agreement_uuid, remote_url) VALUES ($1, $2, $3, $4, $5, $6) ON CONFLICT DO NOTHING`, [userId, title, markdown, hash, agreementUuid, uri], ); await client.query(`DELETE FROM agreement_fetch WHERE agreement_uuid = $1`, [agreementUuid]); } catch (fetchErr) { await recordFailure(client, agreementUuid, uri, attempts, fetchErr.message, config.agreementCache); } } catch (e) { console.error(`cacheAgreementMarkdown(${agreementUuid}):`, e.message); } finally { if (!_client) await client.release(); } } // Bump the attempt counter and schedule the next try with exponential backoff. async function recordFailure(client, agreementUuid, uri, priorFailures, message, cfg) { const delayMs = Math.round(backoffMs(priorFailures, cfg)); await client.query( `INSERT INTO agreement_fetch (agreement_uuid, remote_url, attempts, next_attempt_ts, last_error) VALUES ($1, $2, $3, NOW() + ($4 || ' milliseconds')::interval, $5) ON CONFLICT (agreement_uuid) DO UPDATE SET attempts = $3, next_attempt_ts = NOW() + ($4 || ' milliseconds')::interval, last_error = $5, remote_url = $2, updated_ts = NOW()`, [agreementUuid, uri, priorFailures + 1, String(delayMs), message], ); }