feat: add engine and store snapshot/restore for restart resilience
This commit is contained in:
@@ -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 = {
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
@@ -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");
|
||||||
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user