harden state durability and disable destructive snapshot sync
This commit is contained in:
@@ -23,6 +23,7 @@ export const saveSnapshot = mutation({
|
|||||||
args: {
|
args: {
|
||||||
snapshot: v.any(),
|
snapshot: v.any(),
|
||||||
updatedAt: v.string(),
|
updatedAt: v.string(),
|
||||||
|
syncToDomain: v.optional(v.boolean()),
|
||||||
},
|
},
|
||||||
handler: async (ctx, args) => {
|
handler: async (ctx, args) => {
|
||||||
const latest = await ctx.db
|
const latest = await ctx.db
|
||||||
@@ -30,7 +31,10 @@ export const saveSnapshot = mutation({
|
|||||||
.order("desc")
|
.order("desc")
|
||||||
.first();
|
.first();
|
||||||
|
|
||||||
const syncSummary = await syncFromEngineSnapshot(ctx, args.snapshot);
|
const shouldSync = Boolean(args.syncToDomain);
|
||||||
|
const syncSummary = shouldSync
|
||||||
|
? await syncFromEngineSnapshot(ctx, args.snapshot)
|
||||||
|
: null;
|
||||||
|
|
||||||
if (latest) {
|
if (latest) {
|
||||||
await ctx.db.patch(latest._id, {
|
await ctx.db.patch(latest._id, {
|
||||||
|
|||||||
64
src/app.js
64
src/app.js
@@ -126,20 +126,15 @@ function buildApp({
|
|||||||
windowMs: 60_000,
|
windowMs: 60_000,
|
||||||
});
|
});
|
||||||
|
|
||||||
function persistMutation() {
|
async function persistMutation() {
|
||||||
if (!onMutation) {
|
if (!onMutation) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
await onMutation({
|
||||||
try {
|
version: 1,
|
||||||
onMutation({
|
updatedAt: new Date().toISOString(),
|
||||||
version: 1,
|
engine: engine.exportState(),
|
||||||
updatedAt: new Date().toISOString(),
|
});
|
||||||
engine: engine.exportState(),
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
logger.error({ err: error }, "failed to persist mutation");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function clientAddressFromHeaders(headers) {
|
function clientAddressFromHeaders(headers) {
|
||||||
@@ -174,7 +169,9 @@ function buildApp({
|
|||||||
if (!generationService || !generationService.isConfigured()) {
|
if (!generationService || !generationService.isConfigured()) {
|
||||||
try {
|
try {
|
||||||
engine.completeJob(job.id);
|
engine.completeJob(job.id);
|
||||||
persistMutation();
|
void persistMutation().catch((error) => {
|
||||||
|
logger.error({ err: error, jobId: job.id }, "failed to persist completion without generation worker");
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error({ err: error, jobId: job.id }, "failed to mark job as completed without generation worker");
|
logger.error({ err: error, jobId: job.id }, "failed to mark job as completed without generation worker");
|
||||||
}
|
}
|
||||||
@@ -183,7 +180,9 @@ function buildApp({
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
engine.startJob(job.id);
|
engine.startJob(job.id);
|
||||||
persistMutation();
|
void persistMutation().catch((error) => {
|
||||||
|
logger.error({ err: error, jobId: job.id }, "failed to persist job start");
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error({ err: error, jobId: job.id }, "failed to start audio generation job");
|
logger.error({ err: error, jobId: job.id }, "failed to start audio generation job");
|
||||||
return;
|
return;
|
||||||
@@ -196,8 +195,13 @@ function buildApp({
|
|||||||
onCompleted: (audioMeta) => {
|
onCompleted: (audioMeta) => {
|
||||||
try {
|
try {
|
||||||
engine.completeJob(job.id, audioMeta);
|
engine.completeJob(job.id, audioMeta);
|
||||||
persistMutation();
|
void persistMutation()
|
||||||
logger.info({ assetId: job.assetId, jobId: job.id }, "audio generation completed");
|
.then(() => {
|
||||||
|
logger.info({ assetId: job.assetId, jobId: job.id }, "audio generation completed");
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
logger.error({ err: error, assetId: job.assetId, jobId: job.id }, "failed to persist completed job");
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error({ err: error, assetId: job.assetId }, "failed to apply generated audio metadata");
|
logger.error({ err: error, assetId: job.assetId }, "failed to apply generated audio metadata");
|
||||||
}
|
}
|
||||||
@@ -208,7 +212,9 @@ function buildApp({
|
|||||||
error: error && error.message ? error.message : "audio_generation_failed",
|
error: error && error.message ? error.message : "audio_generation_failed",
|
||||||
refund: true,
|
refund: true,
|
||||||
});
|
});
|
||||||
persistMutation();
|
void persistMutation().catch((persistError) => {
|
||||||
|
logger.error({ err: persistError, jobId: job.id }, "failed to persist failed job state");
|
||||||
|
});
|
||||||
} catch (failureError) {
|
} catch (failureError) {
|
||||||
logger.error({ err: failureError, jobId: job.id }, "failed to mark generation failure");
|
logger.error({ err: failureError, jobId: job.id }, "failed to mark generation failure");
|
||||||
}
|
}
|
||||||
@@ -392,7 +398,7 @@ function buildApp({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
persistMutation();
|
await persistMutation();
|
||||||
scheduleAudioGeneration(result.job);
|
scheduleAudioGeneration(result.job);
|
||||||
const replyMessage = result.reply
|
const replyMessage = result.reply
|
||||||
? result.reply.message
|
? result.reply.message
|
||||||
@@ -413,7 +419,7 @@ function buildApp({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handlePolarWebhook(headers, rawBody) {
|
async function handlePolarWebhook(headers, rawBody) {
|
||||||
try {
|
try {
|
||||||
let payload;
|
let payload;
|
||||||
|
|
||||||
@@ -441,7 +447,7 @@ function buildApp({
|
|||||||
}
|
}
|
||||||
|
|
||||||
engine.topUpCredits(payload.userId, payload.credits, `polar:${payload.eventId}`);
|
engine.topUpCredits(payload.userId, payload.credits, `polar:${payload.eventId}`);
|
||||||
persistMutation();
|
await persistMutation();
|
||||||
return json(200, { status: "credited" });
|
return json(200, { status: "credited" });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.warn({ err: error }, "polar webhook request failed");
|
logger.warn({ err: error }, "polar webhook request failed");
|
||||||
@@ -645,7 +651,7 @@ function buildApp({
|
|||||||
}
|
}
|
||||||
|
|
||||||
engine.topUpCredits(userId, amount, `app-topup:${userId}:${randomUUID()}`);
|
engine.topUpCredits(userId, amount, `app-topup:${userId}:${randomUUID()}`);
|
||||||
persistMutation();
|
await persistMutation();
|
||||||
return redirect(withQuery("/app", { flash: `Added ${amount} credits` }));
|
return redirect(withQuery("/app", { flash: `Added ${amount} credits` }));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -701,7 +707,7 @@ function buildApp({
|
|||||||
return redirect(withQuery("/app", { flash: "Parent post is not an article" }));
|
return redirect(withQuery("/app", { flash: "Parent post is not an article" }));
|
||||||
}
|
}
|
||||||
|
|
||||||
persistMutation();
|
await persistMutation();
|
||||||
scheduleAudioGeneration(result.job);
|
scheduleAudioGeneration(result.job);
|
||||||
return redirect(withQuery(`/audio/${result.job.assetId}`, {
|
return redirect(withQuery(`/audio/${result.job.assetId}`, {
|
||||||
flash: "Audiobook generated",
|
flash: "Audiobook generated",
|
||||||
@@ -725,7 +731,7 @@ function buildApp({
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
engine.unlockAudio(assetId, userId);
|
engine.unlockAudio(assetId, userId);
|
||||||
persistMutation();
|
await persistMutation();
|
||||||
return redirect(withQuery(`/audio/${assetId}`, { flash: "Unlocked" }));
|
return redirect(withQuery(`/audio/${assetId}`, { flash: "Unlocked" }));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return redirect(withQuery(`/audio/${assetId}`, { flash: `Unlock failed: ${error.message}` }));
|
return redirect(withQuery(`/audio/${assetId}`, { flash: `Unlock failed: ${error.message}` }));
|
||||||
@@ -821,7 +827,7 @@ function buildApp({
|
|||||||
rawArticleHours: Number.isFinite(payload.rawArticleHours) ? payload.rawArticleHours : 24,
|
rawArticleHours: Number.isFinite(payload.rawArticleHours) ? payload.rawArticleHours : 24,
|
||||||
audioDays: Number.isFinite(payload.audioDays) ? payload.audioDays : 90,
|
audioDays: Number.isFinite(payload.audioDays) ? payload.audioDays : 90,
|
||||||
});
|
});
|
||||||
persistMutation();
|
await persistMutation();
|
||||||
return json(200, { status: "ok", summary });
|
return json(200, { status: "ok", summary });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -830,7 +836,7 @@ function buildApp({
|
|||||||
if (rateLimited) {
|
if (rateLimited) {
|
||||||
return rateLimited;
|
return rateLimited;
|
||||||
}
|
}
|
||||||
return handlePolarWebhook(safeHeaders, rawBody);
|
return await handlePolarWebhook(safeHeaders, rawBody);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (method === "POST" && path === "/api/payments/create-checkout") {
|
if (method === "POST" && path === "/api/payments/create-checkout") {
|
||||||
@@ -895,7 +901,7 @@ function buildApp({
|
|||||||
const assetId = path.slice("/api/audio/".length, -"/unlock".length);
|
const assetId = path.slice("/api/audio/".length, -"/unlock".length);
|
||||||
try {
|
try {
|
||||||
const result = engine.unlockAudio(assetId, userId);
|
const result = engine.unlockAudio(assetId, userId);
|
||||||
persistMutation();
|
await persistMutation();
|
||||||
return json(200, result);
|
return json(200, result);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return json(400, { error: error.message });
|
return json(400, { error: error.message });
|
||||||
@@ -910,7 +916,7 @@ function buildApp({
|
|||||||
const assetId = path.slice("/api/audio/".length);
|
const assetId = path.slice("/api/audio/".length);
|
||||||
try {
|
try {
|
||||||
const deleted = engine.takedownAudio(assetId, userId);
|
const deleted = engine.takedownAudio(assetId, userId);
|
||||||
persistMutation();
|
await persistMutation();
|
||||||
return json(200, { status: "deleted", assetId: deleted.id });
|
return json(200, { status: "deleted", assetId: deleted.id });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const status = error.message === "forbidden" ? 403 : 400;
|
const status = error.message === "forbidden" ? 403 : 400;
|
||||||
@@ -927,7 +933,7 @@ function buildApp({
|
|||||||
const jobId = path.slice("/internal/jobs/".length, -"/start".length);
|
const jobId = path.slice("/internal/jobs/".length, -"/start".length);
|
||||||
try {
|
try {
|
||||||
const job = engine.startJob(jobId);
|
const job = engine.startJob(jobId);
|
||||||
persistMutation();
|
await persistMutation();
|
||||||
return json(200, { job });
|
return json(200, { job });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return json(400, { error: error.message });
|
return json(400, { error: error.message });
|
||||||
@@ -945,7 +951,7 @@ function buildApp({
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const job = engine.completeJob(jobId, payload.asset || {});
|
const job = engine.completeJob(jobId, payload.asset || {});
|
||||||
persistMutation();
|
await persistMutation();
|
||||||
return json(200, { job });
|
return json(200, { job });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return json(400, { error: error.message });
|
return json(400, { error: error.message });
|
||||||
@@ -967,7 +973,7 @@ function buildApp({
|
|||||||
error: payload.error || "generation_failed",
|
error: payload.error || "generation_failed",
|
||||||
refund: shouldRefund,
|
refund: shouldRefund,
|
||||||
});
|
});
|
||||||
persistMutation();
|
await persistMutation();
|
||||||
return json(200, { job });
|
return json(200, { job });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return json(400, { error: error.message });
|
return json(400, { error: error.message });
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ function boolFromEnv(name, fallback) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const parsed = {
|
const parsed = {
|
||||||
|
nodeEnv: strFromEnv("NODE_ENV", "development"),
|
||||||
port: intFromEnv("PORT", 3000),
|
port: intFromEnv("PORT", 3000),
|
||||||
logLevel: strFromEnv("LOG_LEVEL", "info"),
|
logLevel: strFromEnv("LOG_LEVEL", "info"),
|
||||||
appBaseUrl: strFromEnv("APP_BASE_URL", "http://localhost:3000"),
|
appBaseUrl: strFromEnv("APP_BASE_URL", "http://localhost:3000"),
|
||||||
@@ -100,7 +101,13 @@ const parsed = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
parsed.allowInMemoryStateFallback = boolFromEnv(
|
||||||
|
"ALLOW_IN_MEMORY_STATE_FALLBACK",
|
||||||
|
parsed.nodeEnv !== "production",
|
||||||
|
);
|
||||||
|
|
||||||
const ConfigSchema = z.object({
|
const ConfigSchema = z.object({
|
||||||
|
nodeEnv: z.string().min(1),
|
||||||
port: z.number().int().positive(),
|
port: z.number().int().positive(),
|
||||||
logLevel: z.enum(["fatal", "error", "warn", "info", "debug", "trace", "silent"]),
|
logLevel: z.enum(["fatal", "error", "warn", "info", "debug", "trace", "silent"]),
|
||||||
appBaseUrl: z.string().min(1),
|
appBaseUrl: z.string().min(1),
|
||||||
@@ -150,6 +157,7 @@ const ConfigSchema = z.object({
|
|||||||
stepCredits: z.number().int().positive(),
|
stepCredits: z.number().int().positive(),
|
||||||
maxCharsPerArticle: z.number().int().positive(),
|
maxCharsPerArticle: z.number().int().positive(),
|
||||||
}),
|
}),
|
||||||
|
allowInMemoryStateFallback: z.boolean(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const config = ConfigSchema.parse(parsed);
|
const config = ConfigSchema.parse(parsed);
|
||||||
|
|||||||
@@ -64,13 +64,19 @@ function createHttpServer({ app }) {
|
|||||||
|
|
||||||
function createMutationPersister({ stateStore, logger = console }) {
|
function createMutationPersister({ stateStore, logger = console }) {
|
||||||
let queue = Promise.resolve();
|
let queue = Promise.resolve();
|
||||||
|
let lastError = null;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
enqueue(state) {
|
enqueue(state) {
|
||||||
queue = queue
|
queue = queue
|
||||||
.then(() => stateStore.save(state))
|
.then(
|
||||||
|
() => stateStore.save(state),
|
||||||
|
() => stateStore.save(state),
|
||||||
|
)
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
|
lastError = error;
|
||||||
logger.error({ err: error }, "failed to persist state");
|
logger.error({ err: error }, "failed to persist state");
|
||||||
|
throw error;
|
||||||
});
|
});
|
||||||
|
|
||||||
return queue;
|
return queue;
|
||||||
@@ -78,6 +84,9 @@ function createMutationPersister({ stateStore, logger = console }) {
|
|||||||
flush() {
|
flush() {
|
||||||
return queue;
|
return queue;
|
||||||
},
|
},
|
||||||
|
getLastError() {
|
||||||
|
return lastError;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,10 +101,15 @@ async function createRuntime({ runtimeConfig = config, logger = console, stateSt
|
|||||||
try {
|
try {
|
||||||
initialState = await effectiveStateStore.load();
|
initialState = await effectiveStateStore.load();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.warn(
|
const allowFallback = runtimeConfig.allowInMemoryStateFallback !== undefined
|
||||||
{ err: error },
|
? Boolean(runtimeConfig.allowInMemoryStateFallback)
|
||||||
"failed to initialize configured state store; falling back to in-memory state",
|
: true;
|
||||||
);
|
|
||||||
|
if (!allowFallback) {
|
||||||
|
throw new Error("state_store_unavailable_without_fallback", { cause: error });
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.warn({ err: error }, "failed to initialize configured state store; falling back to in-memory state");
|
||||||
effectiveStateStore = new InMemoryStateStore();
|
effectiveStateStore = new InMemoryStateStore();
|
||||||
initialState = await effectiveStateStore.load();
|
initialState = await effectiveStateStore.load();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ function withTempEnv(patch, run) {
|
|||||||
|
|
||||||
test("config uses defaults when env is missing", () => {
|
test("config uses defaults when env is missing", () => {
|
||||||
withTempEnv({
|
withTempEnv({
|
||||||
|
NODE_ENV: "",
|
||||||
PORT: "",
|
PORT: "",
|
||||||
LOG_LEVEL: "",
|
LOG_LEVEL: "",
|
||||||
APP_BASE_URL: "",
|
APP_BASE_URL: "",
|
||||||
@@ -42,8 +43,10 @@ test("config uses defaults when env is missing", () => {
|
|||||||
MINIO_SIGNED_URL_TTL_SEC: "",
|
MINIO_SIGNED_URL_TTL_SEC: "",
|
||||||
MINIO_USE_SSL: "",
|
MINIO_USE_SSL: "",
|
||||||
WEBHOOK_RPM: "",
|
WEBHOOK_RPM: "",
|
||||||
|
ALLOW_IN_MEMORY_STATE_FALLBACK: "",
|
||||||
}, () => {
|
}, () => {
|
||||||
const { config } = require("../src/config");
|
const { config } = require("../src/config");
|
||||||
|
assert.equal(config.nodeEnv, "development");
|
||||||
assert.equal(config.port, 3000);
|
assert.equal(config.port, 3000);
|
||||||
assert.equal(config.logLevel, "info");
|
assert.equal(config.logLevel, "info");
|
||||||
assert.equal(config.appBaseUrl, "http://localhost:3000");
|
assert.equal(config.appBaseUrl, "http://localhost:3000");
|
||||||
@@ -55,6 +58,7 @@ test("config uses defaults when env is missing", () => {
|
|||||||
assert.equal(config.minioSignedUrlTtlSec, 3600);
|
assert.equal(config.minioSignedUrlTtlSec, 3600);
|
||||||
assert.equal(config.minioUseSSL, true);
|
assert.equal(config.minioUseSSL, true);
|
||||||
assert.equal(config.rateLimits.webhookPerMinute, 120);
|
assert.equal(config.rateLimits.webhookPerMinute, 120);
|
||||||
|
assert.equal(config.allowInMemoryStateFallback, true);
|
||||||
assert.equal(config.abuse.maxJobsPerUserPerDay, 0);
|
assert.equal(config.abuse.maxJobsPerUserPerDay, 0);
|
||||||
assert.equal(config.abuse.cooldownSec, 0);
|
assert.equal(config.abuse.cooldownSec, 0);
|
||||||
assert.deepEqual(config.abuse.denyUserIds, []);
|
assert.deepEqual(config.abuse.denyUserIds, []);
|
||||||
@@ -63,6 +67,7 @@ test("config uses defaults when env is missing", () => {
|
|||||||
|
|
||||||
test("config reads convex/qwen/minio overrides", () => {
|
test("config reads convex/qwen/minio overrides", () => {
|
||||||
withTempEnv({
|
withTempEnv({
|
||||||
|
NODE_ENV: "production",
|
||||||
PORT: "8080",
|
PORT: "8080",
|
||||||
LOG_LEVEL: "debug",
|
LOG_LEVEL: "debug",
|
||||||
APP_BASE_URL: "https://xartaudio.app",
|
APP_BASE_URL: "https://xartaudio.app",
|
||||||
@@ -86,8 +91,10 @@ test("config reads convex/qwen/minio overrides", () => {
|
|||||||
ABUSE_MAX_JOBS_PER_USER_PER_DAY: "5",
|
ABUSE_MAX_JOBS_PER_USER_PER_DAY: "5",
|
||||||
ABUSE_COOLDOWN_SEC: "120",
|
ABUSE_COOLDOWN_SEC: "120",
|
||||||
ABUSE_DENY_USER_IDS: "u1,u2",
|
ABUSE_DENY_USER_IDS: "u1,u2",
|
||||||
|
ALLOW_IN_MEMORY_STATE_FALLBACK: "",
|
||||||
}, () => {
|
}, () => {
|
||||||
const { config } = require("../src/config");
|
const { config } = require("../src/config");
|
||||||
|
assert.equal(config.nodeEnv, "production");
|
||||||
assert.equal(config.port, 8080);
|
assert.equal(config.port, 8080);
|
||||||
assert.equal(config.logLevel, "debug");
|
assert.equal(config.logLevel, "debug");
|
||||||
assert.equal(config.appBaseUrl, "https://xartaudio.app");
|
assert.equal(config.appBaseUrl, "https://xartaudio.app");
|
||||||
@@ -109,6 +116,17 @@ test("config reads convex/qwen/minio overrides", () => {
|
|||||||
assert.equal(config.abuse.maxJobsPerUserPerDay, 5);
|
assert.equal(config.abuse.maxJobsPerUserPerDay, 5);
|
||||||
assert.equal(config.abuse.cooldownSec, 120);
|
assert.equal(config.abuse.cooldownSec, 120);
|
||||||
assert.deepEqual(config.abuse.denyUserIds, ["u1", "u2"]);
|
assert.deepEqual(config.abuse.denyUserIds, ["u1", "u2"]);
|
||||||
|
assert.equal(config.allowInMemoryStateFallback, false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("allow in-memory fallback can be explicitly enabled in production", () => {
|
||||||
|
withTempEnv({
|
||||||
|
NODE_ENV: "production",
|
||||||
|
ALLOW_IN_MEMORY_STATE_FALLBACK: "true",
|
||||||
|
}, () => {
|
||||||
|
const { config } = require("../src/config");
|
||||||
|
assert.equal(config.allowInMemoryStateFallback, true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ const {
|
|||||||
|
|
||||||
function createRuntimeConfig() {
|
function createRuntimeConfig() {
|
||||||
return {
|
return {
|
||||||
|
nodeEnv: "test",
|
||||||
port: 3000,
|
port: 3000,
|
||||||
logLevel: "info",
|
logLevel: "info",
|
||||||
appBaseUrl: "http://localhost:3000",
|
appBaseUrl: "http://localhost:3000",
|
||||||
@@ -60,6 +61,7 @@ function createRuntimeConfig() {
|
|||||||
stepCredits: 1,
|
stepCredits: 1,
|
||||||
maxCharsPerArticle: 120000,
|
maxCharsPerArticle: 120000,
|
||||||
},
|
},
|
||||||
|
allowInMemoryStateFallback: true,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -153,3 +155,39 @@ test("createRuntime falls back to in-memory state when initial load fails", asyn
|
|||||||
assert.equal(response.status, 303);
|
assert.equal(response.status, 303);
|
||||||
await runtime.persister.flush();
|
await runtime.persister.flush();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("createRuntime fails startup when fallback is disabled", async () => {
|
||||||
|
const runtimeConfig = createRuntimeConfig();
|
||||||
|
runtimeConfig.allowInMemoryStateFallback = false;
|
||||||
|
|
||||||
|
await assert.rejects(
|
||||||
|
createRuntime({
|
||||||
|
runtimeConfig,
|
||||||
|
logger: { info() {}, warn() {}, error() {} },
|
||||||
|
stateStore: {
|
||||||
|
async load() {
|
||||||
|
throw new Error("state_load_failed");
|
||||||
|
},
|
||||||
|
async save() {},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
/state_store_unavailable_without_fallback/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("createMutationPersister surfaces save errors", async () => {
|
||||||
|
const persister = createMutationPersister({
|
||||||
|
stateStore: {
|
||||||
|
async save() {
|
||||||
|
throw new Error("persist_failed");
|
||||||
|
},
|
||||||
|
},
|
||||||
|
logger: { error() {} },
|
||||||
|
});
|
||||||
|
|
||||||
|
await assert.rejects(
|
||||||
|
persister.enqueue({}),
|
||||||
|
/persist_failed/,
|
||||||
|
);
|
||||||
|
assert.equal(persister.getLastError()?.message, "persist_failed");
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user