From c714c2eaec060fbc56f7791bc79c5cd04241f644 Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 18 Feb 2026 12:59:15 +0000 Subject: [PATCH] feat: emit app state snapshots on all mutating workflows --- src/app.js | 26 +++++++++++++++++++++++++- test/app.test.js | 32 +++++++++++++++++++++++++++++++- 2 files changed, 56 insertions(+), 2 deletions(-) diff --git a/src/app.js b/src/app.js index 27ae38f..1d41b0a 100644 --- a/src/app.js +++ b/src/app.js @@ -48,11 +48,28 @@ function parsePositiveInt(raw, fallback = null) { return parsed; } -function buildApp({ config }) { +function buildApp({ config, initialState = null, onMutation = null }) { const engine = new XArtAudioEngine({ creditConfig: config.credit, + initialState: initialState && initialState.engine ? initialState.engine : null, }); + function persistMutation() { + if (!onMutation) { + return; + } + + try { + onMutation({ + version: 1, + updatedAt: new Date().toISOString(), + engine: engine.exportState(), + }); + } catch { + // avoid breaking request flow on persistence callback issues + } + } + function ensureAuth(userId, returnTo) { if (userId) { return null; @@ -92,6 +109,8 @@ function buildApp({ config }) { }); } + persistMutation(); + return json(200, { status: "completed", deduped: result.deduped, @@ -120,6 +139,7 @@ function buildApp({ config }) { try { engine.topUpCredits(payload.userId, payload.credits, `polar:${payload.eventId}`); + persistMutation(); return json(200, { status: "credited" }); } catch (error) { return json(400, { error: error.message }); @@ -224,6 +244,7 @@ function buildApp({ config }) { } engine.topUpCredits(userId, amount, `app-topup:${userId}:${randomUUID()}`); + persistMutation(); return redirect(withQuery("/app", { flash: `Added ${amount} credits` })); } @@ -260,6 +281,7 @@ function buildApp({ config }) { return redirect(withQuery("/app", { flash: "Parent post is not an article" })); } + persistMutation(); return redirect(withQuery(`/audio/${result.job.assetId}`, { flash: "Audiobook generated", })); @@ -277,6 +299,7 @@ function buildApp({ config }) { try { engine.unlockAudio(assetId, userId); + persistMutation(); return redirect(withQuery(`/audio/${assetId}`, { flash: "Unlocked" })); } catch (error) { return redirect(withQuery(`/audio/${assetId}`, { flash: `Unlock failed: ${error.message}` })); @@ -330,6 +353,7 @@ function buildApp({ config }) { const assetId = path.slice("/api/audio/".length, -"/unlock".length); try { const result = engine.unlockAudio(assetId, userId); + persistMutation(); return json(200, result); } catch (error) { return json(400, { error: error.message }); diff --git a/test/app.test.js b/test/app.test.js index 25da00a..590080f 100644 --- a/test/app.test.js +++ b/test/app.test.js @@ -5,7 +5,7 @@ const assert = require("node:assert/strict"); const { buildApp } = require("../src/app"); const { hmacSHA256Hex } = require("../src/lib/signature"); -function createApp() { +function createApp(options = {}) { return buildApp({ config: { xWebhookSecret: "x-secret", @@ -18,6 +18,7 @@ function createApp() { maxCharsPerArticle: 120000, }, }, + ...options, }); } @@ -194,3 +195,32 @@ test("X webhook valid flow processes article", () => { assert.equal(body.status, "completed"); assert.equal(body.creditsCharged, 1); }); + +test("emits persistence snapshots on mutating actions", () => { + const snapshots = []; + const app = createApp({ + onMutation(state) { + snapshots.push(state); + }, + }); + + call(app, { + method: "POST", + path: "/app/actions/topup", + headers: { cookie: "xartaudio_user=alice" }, + body: "amount=5", + }); + + call(app, { + method: "POST", + path: "/app/actions/simulate-mention", + headers: { cookie: "xartaudio_user=alice" }, + body: "title=Persisted&body=hello", + }); + + assert.equal(snapshots.length >= 2, true); + const latest = snapshots[snapshots.length - 1]; + assert.equal(latest.version, 1); + assert.equal(typeof latest.updatedAt, "string"); + assert.equal(typeof latest.engine, "object"); +});