From 9b3cdbff1afb81be7c9614cbf64fc5cdc423b2fd Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 18 Feb 2026 12:57:25 +0000 Subject: [PATCH] feat: add engine and store snapshot/restore for restart resilience --- src/lib/access.js | 14 ++++++++++++-- src/lib/engine.js | 32 ++++++++++++++++++++++++-------- src/lib/wallet.js | 26 +++++++++++++++++++++----- test/access.test.js | 10 ++++++++++ test/engine.test.js | 26 ++++++++++++++++++++++++++ test/wallet.test.js | 24 ++++++++++++++++++++++++ 6 files changed, 117 insertions(+), 15 deletions(-) diff --git a/src/lib/access.js b/src/lib/access.js index c315c69..fa26a2a 100644 --- a/src/lib/access.js +++ b/src/lib/access.js @@ -1,8 +1,9 @@ "use strict"; class AudioAccessStore { - constructor() { - this.grants = new Map(); + constructor(initialState) { + const entries = Object.entries((initialState && initialState.grants) || {}); + this.grants = new Map(entries.map(([audioId, userIds]) => [audioId, new Set(userIds)])); } _key(audioId) { @@ -71,6 +72,15 @@ class AudioAccessStore { this.grantAccess(audio.id, userId); return { unlocked: true, charged: audio.creditsCharged }; } + + exportState() { + const grants = {}; + for (const [audioId, users] of this.grants.entries()) { + grants[audioId] = Array.from(users.values()); + } + + return { grants }; + } } module.exports = { diff --git a/src/lib/engine.js b/src/lib/engine.js index a78d568..13b13dc 100644 --- a/src/lib/engine.js +++ b/src/lib/engine.js @@ -6,17 +6,21 @@ const { calculateCredits } = require("./credits"); const { extractArticleFromParent } = require("./article"); class XArtAudioEngine { - constructor({ creditConfig }) { + constructor({ creditConfig, initialState }) { this.creditConfig = creditConfig; - this.wallets = new WalletStore(); - this.access = new AudioAccessStore(); + this.wallets = new WalletStore(initialState && initialState.wallets); + this.access = new AudioAccessStore(initialState && initialState.access); - this.jobs = new Map(); - this.assets = new Map(); - this.mentions = new Map(); + this.jobs = new Map(Object.entries((initialState && initialState.jobs) || {})); + this.assets = new Map(Object.entries((initialState && initialState.assets) || {})); + this.mentions = new Map(Object.entries((initialState && initialState.mentions) || {})); - this.nextJobId = 1; - this.nextAssetId = 1; + this.nextJobId = Number.isInteger(initialState && initialState.nextJobId) + ? initialState.nextJobId + : 1; + this.nextAssetId = Number.isInteger(initialState && initialState.nextAssetId) + ? initialState.nextAssetId + : 1; } topUpCredits(userId, amount, idempotencyKey) { @@ -166,6 +170,18 @@ class XArtAudioEngine { wallet: this.wallets, }); } + + exportState() { + return { + wallets: this.wallets.exportState(), + access: this.access.exportState(), + jobs: Object.fromEntries(this.jobs.entries()), + assets: Object.fromEntries(this.assets.entries()), + mentions: Object.fromEntries(this.mentions.entries()), + nextJobId: this.nextJobId, + nextAssetId: this.nextAssetId, + }; + } } module.exports = { diff --git a/src/lib/wallet.js b/src/lib/wallet.js index c0fde63..5a06c3b 100644 --- a/src/lib/wallet.js +++ b/src/lib/wallet.js @@ -1,11 +1,19 @@ "use strict"; class WalletStore { - constructor() { - this.balances = new Map(); - this.transactions = []; - this.byIdempotencyKey = new Map(); - this.nextId = 1; + constructor(initialState) { + this.balances = new Map(Object.entries((initialState && initialState.balances) || {})); + this.transactions = Array.isArray(initialState && initialState.transactions) + ? [...initialState.transactions] + : []; + this.byIdempotencyKey = new Map( + this.transactions + .filter((tx) => tx.idempotencyKey) + .map((tx) => [tx.idempotencyKey, tx]), + ); + this.nextId = Number.isInteger(initialState && initialState.nextId) + ? initialState.nextId + : (this.transactions.length + 1); } getBalance(userId) { @@ -63,6 +71,14 @@ class WalletStore { this.balances.set(userId, newBalance); return tx; } + + exportState() { + return { + balances: Object.fromEntries(this.balances.entries()), + transactions: [...this.transactions], + nextId: this.nextId, + }; + } } module.exports = { diff --git a/test/access.test.js b/test/access.test.js index 4dfa9b7..721e0e7 100644 --- a/test/access.test.js +++ b/test/access.test.js @@ -65,3 +65,13 @@ test("second unlock call is idempotent and does not double charge", () => { assert.equal(wallet.getBalance("u2"), 7); }); + +test("restores grant state from snapshot", () => { + const access = new AudioAccessStore(); + access.grantAccess("a1", "u2"); + + const restored = new AudioAccessStore(access.exportState()); + const canAccess = restored.canAccess({ audio, userId: "u2" }); + assert.equal(canAccess.allowed, true); + assert.equal(canAccess.reason, "grant"); +}); diff --git a/test/engine.test.js b/test/engine.test.js index 9a7e113..253ca1a 100644 --- a/test/engine.test.js +++ b/test/engine.test.js @@ -154,3 +154,29 @@ test("lists jobs for user newest first and provides summary", () => { assert.equal(summary.totalCreditsSpent, 2); assert.equal(summary.balance, 8); }); + +test("round-trips state snapshot across engine restart", () => { + const engine1 = createEngine(); + engine1.topUpCredits("u1", 5, "topup-snapshot"); + + const created = engine1.processMention({ + mentionPostId: "m-snapshot", + callerUserId: "u1", + parentPost: { + id: "p-snapshot", + authorId: "author-snapshot", + article: { id: "a-snapshot", title: "Snap", body: "hello world" }, + }, + }); + + const snapshot = engine1.exportState(); + const engine2 = new XArtAudioEngine({ + creditConfig: engine1.creditConfig, + initialState: snapshot, + }); + + assert.equal(engine2.getWalletBalance("u1"), 4); + assert.equal(engine2.getJob(created.job.id).article.title, "Snap"); + assert.equal(engine2.getAsset(created.job.assetId).articleTitle, "Snap"); + assert.equal(engine2.checkAudioAccess(created.job.assetId, "u1").allowed, true); +}); diff --git a/test/wallet.test.js b/test/wallet.test.js index 5532869..12f0365 100644 --- a/test/wallet.test.js +++ b/test/wallet.test.js @@ -61,3 +61,27 @@ test("is idempotent by idempotency key", () => { assert.equal(first.id, second.id); assert.equal(wallet.getBalance("u1"), 4); }); + +test("can restore state from previous snapshot", () => { + const original = new WalletStore(); + original.applyTransaction({ + userId: "u1", + type: "credit", + amount: 3, + reason: "topup", + idempotencyKey: "evt-r1", + }); + + const restored = new WalletStore(original.exportState()); + assert.equal(restored.getBalance("u1"), 3); + + const duplicate = restored.applyTransaction({ + userId: "u1", + type: "credit", + amount: 3, + reason: "topup", + idempotencyKey: "evt-r1", + }); + assert.equal(duplicate.amount, 3); + assert.equal(restored.getBalance("u1"), 3); +});