diff --git a/src/lib/state-store.js b/src/lib/state-store.js index 340b5ad..94edfc8 100644 --- a/src/lib/state-store.js +++ b/src/lib/state-store.js @@ -32,6 +32,21 @@ class JsonFileStateStore { } } +class InMemoryStateStore { + constructor(initialState = null) { + this.state = initialState; + } + + async load() { + return this.state; + } + + async save(state) { + this.state = state; + } +} + module.exports = { JsonFileStateStore, + InMemoryStateStore, }; diff --git a/src/server.js b/src/server.js index 538bf47..288d364 100644 --- a/src/server.js +++ b/src/server.js @@ -4,6 +4,7 @@ const http = require("node:http"); const { buildApp } = require("./app"); const { config } = require("./config"); const { ConvexStateStore } = require("./lib/convex-state-store"); +const { InMemoryStateStore } = require("./lib/state-store"); const { createLogger } = require("./lib/logger"); function readBody(req) { @@ -81,13 +82,23 @@ function createMutationPersister({ stateStore, logger = console }) { } async function createRuntime({ runtimeConfig = config, logger = console, stateStore = null } = {}) { - const effectiveStateStore = stateStore || new ConvexStateStore({ + let effectiveStateStore = stateStore || new ConvexStateStore({ deploymentUrl: runtimeConfig.convexDeploymentUrl, authToken: runtimeConfig.convexAuthToken, readFunction: runtimeConfig.convexStateQuery, writeFunction: runtimeConfig.convexStateMutation, }); - const initialState = await effectiveStateStore.load(); + let initialState; + try { + initialState = await effectiveStateStore.load(); + } catch (error) { + logger.warn( + { err: error }, + "failed to initialize configured state store; falling back to in-memory state", + ); + effectiveStateStore = new InMemoryStateStore(); + initialState = await effectiveStateStore.load(); + } const persister = createMutationPersister({ stateStore: effectiveStateStore, logger }); const app = buildApp({ diff --git a/test/config.test.js b/test/config.test.js index 71d54b6..3af30c5 100644 --- a/test/config.test.js +++ b/test/config.test.js @@ -36,6 +36,7 @@ test("config uses defaults when env is missing", () => { APP_BASE_URL: "", BETTER_AUTH_SECRET: "", BETTER_AUTH_BASE_PATH: "", + INTERNAL_API_TOKEN: "", QWEN_TTS_MODEL: "", MINIO_SIGNED_URL_TTL_SEC: "", MINIO_USE_SSL: "", diff --git a/test/server.test.js b/test/server.test.js index 72deab4..c60dbc3 100644 --- a/test/server.test.js +++ b/test/server.test.js @@ -2,7 +2,65 @@ const test = require("node:test"); const assert = require("node:assert/strict"); -const { mapToAppRequest, normalizeHeaders, createMutationPersister } = require("../src/server"); +const { + mapToAppRequest, + normalizeHeaders, + createMutationPersister, + createRuntime, +} = require("../src/server"); + +function createRuntimeConfig() { + return { + port: 3000, + logLevel: "info", + appBaseUrl: "http://localhost:3000", + betterAuthSecret: "test-better-auth-secret", + betterAuthBasePath: "/api/auth", + betterAuthDevPassword: "xartaudio-dev-password", + internalApiToken: "", + convexDeploymentUrl: "", + convexAuthToken: "", + convexStateQuery: "state:getLatestSnapshot", + convexStateMutation: "state:saveSnapshot", + xWebhookSecret: "x-secret", + xBearerToken: "", + xBotUserId: "", + polarWebhookSecret: "polar-secret", + polarAccessToken: "", + polarServer: "production", + polarProductIds: [], + qwenTtsApiKey: "", + qwenTtsBaseUrl: "https://dashscope-intl.aliyuncs.com/compatible-mode/v1", + qwenTtsModel: "qwen-tts-latest", + qwenTtsVoice: "Cherry", + qwenTtsFormat: "mp3", + minioEndPoint: "", + minioPort: 443, + minioUseSSL: true, + minioBucket: "", + minioRegion: "us-east-1", + minioAccessKey: "", + minioSecretKey: "", + minioSignedUrlTtlSec: 3600, + rateLimits: { + webhookPerMinute: 120, + authPerMinute: 30, + actionPerMinute: 60, + }, + abuse: { + maxJobsPerUserPerDay: 0, + cooldownSec: 0, + denyUserIds: [], + }, + credit: { + baseCredits: 1, + includedChars: 25000, + stepChars: 10000, + stepCredits: 1, + maxCharsPerArticle: 120000, + }, + }; +} test("normalizeHeaders lowercases and joins array values", () => { const headers = normalizeHeaders({ @@ -49,3 +107,39 @@ test("createMutationPersister writes sequentially and flush waits", async () => assert.deepEqual(saved, ["s1", "s2"]); }); + +test("createRuntime falls back to in-memory state when initial load fails", async () => { + const warnings = []; + const runtime = await createRuntime({ + runtimeConfig: createRuntimeConfig(), + logger: { + warn(payload, message) { + warnings.push({ payload, message }); + }, + info() {}, + error() {}, + }, + stateStore: { + async load() { + throw new Error("state_load_failed"); + }, + async save() { + throw new Error("state_save_should_not_run"); + }, + }, + }); + + assert.equal(warnings.length, 1); + assert.match(String(warnings[0].message), /falling back to in-memory state/); + + const response = await runtime.app.handleRequest({ + method: "POST", + path: "/app/actions/topup", + headers: { "x-user-id": "u1" }, + rawBody: "amount=3", + query: {}, + }); + + assert.equal(response.status, 303); + await runtime.persister.flush(); +}); diff --git a/test/state-store.test.js b/test/state-store.test.js index a60203b..939a4f6 100644 --- a/test/state-store.test.js +++ b/test/state-store.test.js @@ -5,7 +5,7 @@ const assert = require("node:assert/strict"); const os = require("node:os"); const path = require("node:path"); const fs = require("node:fs/promises"); -const { JsonFileStateStore } = require("../src/lib/state-store"); +const { JsonFileStateStore, InMemoryStateStore } = require("../src/lib/state-store"); test("load returns null when state file does not exist", async () => { const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "xartaudio-state-test-")); @@ -33,3 +33,12 @@ test("save and load roundtrip state", async () => { assert.deepEqual(actual, expected); }); + +test("in memory state store roundtrips without filesystem", async () => { + const store = new InMemoryStateStore(); + assert.equal(await store.load(), null); + + await store.save({ engine: { jobs: {} } }); + const loaded = await store.load(); + assert.deepEqual(loaded, { engine: { jobs: {} } }); +});