From 6bd238ad0e400d5ee71ba3e87b10734b05ad226d Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 18 Feb 2026 13:00:51 +0000 Subject: [PATCH] feat: restore/persist app state in server runtime with queued writes --- src/server.js | 75 +++++++++++++++++++++++++++++++++++++++++++-- test/server.test.js | 20 +++++++++++- 2 files changed, 91 insertions(+), 4 deletions(-) diff --git a/src/server.js b/src/server.js index 0af901a..5e7bb32 100644 --- a/src/server.js +++ b/src/server.js @@ -3,6 +3,7 @@ const http = require("node:http"); const { buildApp } = require("./app"); const { config } = require("./config"); +const { JsonFileStateStore } = require("./lib/state-store"); function readBody(req) { return new Promise((resolve, reject) => { @@ -59,10 +60,72 @@ function createHttpServer({ app }) { }); } -function start() { - const app = buildApp({ config }); +function createMutationPersister({ stateStore, logger = console }) { + let queue = Promise.resolve(); + + return { + enqueue(state) { + queue = queue + .then(() => stateStore.save(state)) + .catch((error) => { + logger.error("failed to persist state", error); + }); + + return queue; + }, + flush() { + return queue; + }, + }; +} + +async function createRuntime({ runtimeConfig = config, logger = console } = {}) { + const stateStore = new JsonFileStateStore(runtimeConfig.stateFilePath); + const initialState = await stateStore.load(); + const persister = createMutationPersister({ stateStore, logger }); + + const app = buildApp({ + config: runtimeConfig, + initialState, + onMutation(state) { + void persister.enqueue(state); + }, + }); + const server = createHttpServer({ app }); + return { + app, + server, + persister, + }; +} + +async function start() { + const runtime = await createRuntime({ runtimeConfig: config }); + const { server, persister } = runtime; + + let shuttingDown = false; + async function shutdown(signal) { + if (shuttingDown) { + return; + } + + shuttingDown = true; + // eslint-disable-next-line no-console + console.log(`received ${signal}, shutting down`); + + server.close(); + await persister.flush(); + } + + process.on("SIGTERM", () => { + void shutdown("SIGTERM"); + }); + process.on("SIGINT", () => { + void shutdown("SIGINT"); + }); + server.listen(config.port, () => { // eslint-disable-next-line no-console console.log(`xartaudio server listening on :${config.port}`); @@ -70,12 +133,18 @@ function start() { } if (require.main === module) { - start(); + start().catch((error) => { + // eslint-disable-next-line no-console + console.error("failed to start server", error); + process.exitCode = 1; + }); } module.exports = { mapToAppRequest, normalizeHeaders, createHttpServer, + createMutationPersister, + createRuntime, start, }; diff --git a/test/server.test.js b/test/server.test.js index 1d616cf..72deab4 100644 --- a/test/server.test.js +++ b/test/server.test.js @@ -2,7 +2,7 @@ const test = require("node:test"); const assert = require("node:assert/strict"); -const { mapToAppRequest, normalizeHeaders } = require("../src/server"); +const { mapToAppRequest, normalizeHeaders, createMutationPersister } = require("../src/server"); test("normalizeHeaders lowercases and joins array values", () => { const headers = normalizeHeaders({ @@ -31,3 +31,21 @@ test("mapToAppRequest extracts method/path/headers/body correctly", () => { assert.equal(request.headers["x-signature"], "sha256=abc"); assert.equal(request.rawBody, "{\"ok\":true}"); }); + +test("createMutationPersister writes sequentially and flush waits", async () => { + const saved = []; + const stateStore = { + async save(state) { + await new Promise((resolve) => setTimeout(resolve, 5)); + saved.push(state.id); + }, + }; + + const persister = createMutationPersister({ stateStore, logger: { error() {} } }); + + persister.enqueue({ id: "s1" }); + persister.enqueue({ id: "s2" }); + await persister.flush(); + + assert.deepEqual(saved, ["s1", "s2"]); +});