From c4f6627f10412397ae216a01414cba16f4cbb77c Mon Sep 17 00:00:00 2001 From: paul_motron Date: Sun, 30 Aug 2026 07:20:21 +0100 Subject: [PATCH] Add persistence layer for swapReputationScorer Reputation scores were computed purely in-memory with no durable store, so a score existed only for the duration of the call that produced it. This adds a pluggable ReputationStore interface plus two implementations: - MemoryReputationStore: process-local, non-durable (default for tests/short scripts) - FileReputationStore: scores serialized to JSON on disk, durable across process restarts persistReputationScore/getPersistedReputationScore wrap the existing pure calculateReputationScore so scoring logic itself is untouched. A production multi-instance deployment can back the same interface with the API server's own Redis-backed cache (api-server/src/cache.rs, 'reputation:' key prefix) or a DB table without touching this layer. Closes #878 --- src/__tests__/swapReputationScorer.test.js | 85 ++++++++++++++ src/reputation/swapReputationScorer.js | 126 +++++++++++++++++++++ 2 files changed, 211 insertions(+) diff --git a/src/__tests__/swapReputationScorer.test.js b/src/__tests__/swapReputationScorer.test.js index 613e6e3..85480ca 100644 --- a/src/__tests__/swapReputationScorer.test.js +++ b/src/__tests__/swapReputationScorer.test.js @@ -1,9 +1,17 @@ +const fs = require("fs"); +const os = require("os"); +const path = require("path"); + const { calculateReputationScore, batchCalculateReputation, recencyWeight, scoreTier, STARTING_SCORE, + MemoryReputationStore, + FileReputationStore, + persistReputationScore, + getPersistedReputationScore, } = require("../reputation/swapReputationScorer"); const NOW = new Date("2024-06-01T00:00:00.000Z").getTime(); @@ -106,3 +114,80 @@ describe("batchCalculateReputation", () => { expect(() => batchCalculateReputation([])).toThrow(TypeError); }); }); + +describe("persistence", () => { + describe("MemoryReputationStore", () => { + test("persists and retrieves a score within the same process", () => { + const store = new MemoryReputationStore(); + const history = makeHistory(20); + persistReputationScore({ participantId: "p1", history }, store, NOW); + + const persisted = getPersistedReputationScore("p1", store); + expect(persisted).not.toBeNull(); + expect(persisted.participantId).toBe("p1"); + expect(persisted.updatedAt).toBe(new Date(NOW).toISOString()); + }); + + test("returns null for an unknown participant", () => { + const store = new MemoryReputationStore(); + expect(getPersistedReputationScore("nobody", store)).toBeNull(); + }); + }); + + describe("FileReputationStore", () => { + let filePath; + + beforeEach(() => { + filePath = path.join( + fs.mkdtempSync(path.join(os.tmpdir(), "reputation-store-")), + "scores.json" + ); + }); + + afterEach(() => { + fs.rmSync(path.dirname(filePath), { recursive: true, force: true }); + }); + + test("persists a score across process restarts", () => { + const history = makeHistory(20); + + // "Before restart": compute and persist with one store instance. + const storeBefore = new FileReputationStore(filePath); + const written = persistReputationScore({ participantId: "p1", history }, storeBefore, NOW); + + // "After restart": a brand-new store instance pointed at the same + // file, with no shared in-memory state, must still see the score. + const storeAfter = new FileReputationStore(filePath); + const reloaded = getPersistedReputationScore("p1", storeAfter); + + expect(reloaded).toEqual(written); + expect(reloaded.score).toBe(written.score); + }); + + test("getAll returns every persisted participant after reload", () => { + const storeBefore = new FileReputationStore(filePath); + persistReputationScore({ participantId: "p1", history: makeHistory(20) }, storeBefore, NOW); + persistReputationScore({ participantId: "p2", history: makeHistory(3, "cancelled") }, storeBefore, NOW); + + const storeAfter = new FileReputationStore(filePath); + const ids = storeAfter.getAll().map((r) => r.participantId).sort(); + expect(ids).toEqual(["p1", "p2"]); + }); + + test("returns null for an unknown participant without creating the file early", () => { + const store = new FileReputationStore(filePath); + expect(getPersistedReputationScore("nobody", store)).toBeNull(); + expect(fs.existsSync(filePath)).toBe(false); + }); + }); + + test("persistReputationScore throws without a valid store", () => { + expect(() => + persistReputationScore({ participantId: "p1", history: [] }, {}) + ).toThrow(TypeError); + }); + + test("getPersistedReputationScore throws without a valid store", () => { + expect(() => getPersistedReputationScore("p1", {})).toThrow(TypeError); + }); +}); diff --git a/src/reputation/swapReputationScorer.js b/src/reputation/swapReputationScorer.js index 8dd1d05..e1ac5e4 100644 --- a/src/reputation/swapReputationScorer.js +++ b/src/reputation/swapReputationScorer.js @@ -13,8 +13,35 @@ * - Tenure bonus (account age in days) * - Volume bonus (total swap count) * - Cancellation penalty (cancelled swaps) + * + * Persistence — Issue #878 + * ────────────────────────────────────── + * `calculateReputationScore` / `batchCalculateReputation` above are pure + * functions: given a history they return a score, nothing is written down. + * That's fine for the API/contract layer's own request-scoped caching + * (`api-server/src/cache.rs`, key prefix `reputation:`, TTL-based), but it + * means there is no durable record a score ever existed once that cache + * entry expires or the process restarts. + * + * This module fills that gap with a small `ReputationStore` interface — + * `get(participantId)`, `set(participantId, record)`, `getAll()` — so the + * scoring logic stays decoupled from *where* scores live: + * + * - `MemoryReputationStore` — process-local Map, not durable. Default + * choice for tests and short-lived scripts. + * - `FileReputationStore` — scores serialized to a JSON file on disk. + * Durable across process restarts; intended as the default backend for + * single-instance deployments/tooling that don't have a real DB handy. + * + * A production, multi-instance deployment should back this interface with + * the API server's shared store instead (e.g. the Redis-backed cache in + * `api-server/src/cache.rs`, or a proper DB table) by implementing the + * same three methods — nothing above this layer needs to change. */ +const fs = require("fs"); +const path = require("path"); + const STARTING_SCORE = 500; const MAX_SCORE = 1000; const MIN_SCORE = 0; @@ -129,6 +156,101 @@ function batchCalculateReputation(inputs, nowMs = Date.now()) { .sort((a, b) => b.score - a.score); } +/** + * In-memory reputation store. Not durable — data is lost when the process + * exits. Useful as the default in tests and short-lived scripts, and as a + * reference implementation of the `ReputationStore` interface. + */ +class MemoryReputationStore { + constructor() { + this._records = new Map(); + } + + get(participantId) { + return this._records.get(participantId) ?? null; + } + + set(participantId, record) { + this._records.set(participantId, record); + } + + getAll() { + return Array.from(this._records.values()); + } +} + +/** + * File-backed reputation store. Scores are serialized as JSON to disk, so + * they survive process restarts — the store re-reads from disk on every + * call rather than caching in memory, which keeps it correct if multiple + * short-lived processes share the same file. + */ +class FileReputationStore { + constructor(filePath) { + if (!filePath) throw new TypeError("filePath is required."); + this.filePath = filePath; + } + + _readAll() { + try { + const raw = fs.readFileSync(this.filePath, "utf8"); + return JSON.parse(raw); + } catch (err) { + if (err.code === "ENOENT") return {}; + throw err; + } + } + + _writeAll(records) { + const dir = path.dirname(this.filePath); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(this.filePath, JSON.stringify(records, null, 2)); + } + + get(participantId) { + const records = this._readAll(); + return records[participantId] ?? null; + } + + set(participantId, record) { + const records = this._readAll(); + records[participantId] = record; + this._writeAll(records); + } + + getAll() { + return Object.values(this._readAll()); + } +} + +/** + * Calculate a participant's reputation score and persist it to `store`. + * + * @param {object} input - same shape as `calculateReputationScore`. + * @param {{get, set, getAll}} store - a `ReputationStore` implementation. + * @returns {object} the calculated result (same shape as + * `calculateReputationScore`), plus `updatedAt`. + */ +function persistReputationScore(input, store, nowMs = Date.now()) { + if (!store || typeof store.set !== "function") + throw new TypeError("store must implement the ReputationStore interface."); + + const result = calculateReputationScore(input, nowMs); + const record = { ...result, updatedAt: new Date(nowMs).toISOString() }; + store.set(result.participantId, record); + return record; +} + +/** + * Look up a previously persisted reputation score. Returns `null` if the + * participant has no persisted record. + */ +function getPersistedReputationScore(participantId, store) { + if (!store || typeof store.get !== "function") + throw new TypeError("store must implement the ReputationStore interface."); + return store.get(participantId); +} + module.exports = { calculateReputationScore, batchCalculateReputation, @@ -137,4 +259,8 @@ module.exports = { STARTING_SCORE, MAX_SCORE, MIN_SCORE, + MemoryReputationStore, + FileReputationStore, + persistReputationScore, + getPersistedReputationScore, };