feat: add engine and store snapshot/restore for restart resilience

This commit is contained in:
Codex
2026-02-18 12:57:25 +00:00
parent 76f673fe4c
commit 9b3cdbff1a
6 changed files with 117 additions and 15 deletions

View File

@@ -1,8 +1,9 @@
"use strict"; "use strict";
class AudioAccessStore { class AudioAccessStore {
constructor() { constructor(initialState) {
this.grants = new Map(); const entries = Object.entries((initialState && initialState.grants) || {});
this.grants = new Map(entries.map(([audioId, userIds]) => [audioId, new Set(userIds)]));
} }
_key(audioId) { _key(audioId) {
@@ -71,6 +72,15 @@ class AudioAccessStore {
this.grantAccess(audio.id, userId); this.grantAccess(audio.id, userId);
return { unlocked: true, charged: audio.creditsCharged }; 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 = { module.exports = {

View File

@@ -6,17 +6,21 @@ const { calculateCredits } = require("./credits");
const { extractArticleFromParent } = require("./article"); const { extractArticleFromParent } = require("./article");
class XArtAudioEngine { class XArtAudioEngine {
constructor({ creditConfig }) { constructor({ creditConfig, initialState }) {
this.creditConfig = creditConfig; this.creditConfig = creditConfig;
this.wallets = new WalletStore(); this.wallets = new WalletStore(initialState && initialState.wallets);
this.access = new AudioAccessStore(); this.access = new AudioAccessStore(initialState && initialState.access);
this.jobs = new Map(); this.jobs = new Map(Object.entries((initialState && initialState.jobs) || {}));
this.assets = new Map(); this.assets = new Map(Object.entries((initialState && initialState.assets) || {}));
this.mentions = new Map(); this.mentions = new Map(Object.entries((initialState && initialState.mentions) || {}));
this.nextJobId = 1; this.nextJobId = Number.isInteger(initialState && initialState.nextJobId)
this.nextAssetId = 1; ? initialState.nextJobId
: 1;
this.nextAssetId = Number.isInteger(initialState && initialState.nextAssetId)
? initialState.nextAssetId
: 1;
} }
topUpCredits(userId, amount, idempotencyKey) { topUpCredits(userId, amount, idempotencyKey) {
@@ -166,6 +170,18 @@ class XArtAudioEngine {
wallet: this.wallets, 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 = { module.exports = {

View File

@@ -1,11 +1,19 @@
"use strict"; "use strict";
class WalletStore { class WalletStore {
constructor() { constructor(initialState) {
this.balances = new Map(); this.balances = new Map(Object.entries((initialState && initialState.balances) || {}));
this.transactions = []; this.transactions = Array.isArray(initialState && initialState.transactions)
this.byIdempotencyKey = new Map(); ? [...initialState.transactions]
this.nextId = 1; : [];
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) { getBalance(userId) {
@@ -63,6 +71,14 @@ class WalletStore {
this.balances.set(userId, newBalance); this.balances.set(userId, newBalance);
return tx; return tx;
} }
exportState() {
return {
balances: Object.fromEntries(this.balances.entries()),
transactions: [...this.transactions],
nextId: this.nextId,
};
}
} }
module.exports = { module.exports = {

View File

@@ -65,3 +65,13 @@ test("second unlock call is idempotent and does not double charge", () => {
assert.equal(wallet.getBalance("u2"), 7); 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");
});

View File

@@ -154,3 +154,29 @@ test("lists jobs for user newest first and provides summary", () => {
assert.equal(summary.totalCreditsSpent, 2); assert.equal(summary.totalCreditsSpent, 2);
assert.equal(summary.balance, 8); 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);
});

View File

@@ -61,3 +61,27 @@ test("is idempotent by idempotency key", () => {
assert.equal(first.id, second.id); assert.equal(first.id, second.id);
assert.equal(wallet.getBalance("u1"), 4); 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);
});