fix(persist): use XState v5 getPersistedSnapshot for round-trip

기존 { value, context } 수동 스냅샷은 XState v5 의 restoreSnapshot 이
status/children 등을 기대해서 런타임 에러 발생.

actor.getPersistedSnapshot() 를 써서 완전한 snapshot JSON 을 저장/복원.
getPipelineState 는 snapshot.context 에서 PipelineContext 를 추출.

Dev 서버에서 첫 실행 중 발견.
This commit is contained in:
2026-04-10 16:04:41 +09:00
parent f6c1768c60
commit 7a8f2c1af0

View File

@@ -1,5 +1,5 @@
import { PrismaClient } from "@prisma/client";
import { createActor, type Snapshot } from "xstate";
import { createActor } from "xstate";
import { ulid } from "ulid";
import { pipelineMachine } from "./machine.js";
import { createInitialContext, type PipelineContext } from "./context.js";
@@ -17,6 +17,13 @@ export function getPrisma(): PrismaClient {
return _prisma;
}
/**
* Pipeline persistence layout:
* - pipelines.currentState — XState state value (for quick queries)
* - pipelines.contextJson — FULL persisted snapshot JSON from XState v5
* (includes value, context, status, children, etc.)
*/
export async function createPipeline(
projectName: string,
requirements: string,
@@ -25,20 +32,27 @@ export async function createPipeline(
const pipelineId = ulid();
const ctx = createInitialContext(pipelineId, projectName, requirements);
const actor = createActor(pipelineMachine, {
input: ctx,
});
const actor = createActor(pipelineMachine, { input: ctx });
actor.start();
// Manually set pipelineId into context via assign on fresh start is awkward;
// just store the ctx alongside the persisted snapshot for later restoration.
const persistedSnapshot = actor.getPersistedSnapshot();
const snapshot = actor.getSnapshot();
actor.stop();
// Merge our pipelineId into the persisted context for recovery
const persistedWithId = mergeContextIntoSnapshot(
persistedSnapshot,
ctx,
);
await prisma.pipeline.create({
data: {
id: pipelineId,
projectName,
requirements,
currentState: String(snapshot.value),
contextJson: JSON.stringify(ctx),
contextJson: JSON.stringify(persistedWithId),
},
});
@@ -56,21 +70,22 @@ export async function sendEvent(
where: { id: pipelineId },
});
const ctx = JSON.parse(pipeline.contextJson) as PipelineContext;
const fromState = pipeline.currentState;
const persistedSnapshot = JSON.parse(pipeline.contextJson) as unknown;
// XState v5 accepts a persisted snapshot via the options object.
// We bypass the strict generic typing because the snapshot is produced
// by the same machine and serialised through JSON.
const actor = createActor(pipelineMachine, {
snapshot: {
value: fromState,
context: ctx,
} as unknown as Snapshot<unknown>,
});
snapshot: persistedSnapshot,
} as Parameters<typeof createActor>[1]);
actor.start();
actor.send(event);
const snapshot = actor.getSnapshot();
const toState = String(snapshot.value);
const newContext = snapshot.context as PipelineContext;
const newPersistedSnapshot = actor.getPersistedSnapshot();
actor.stop();
await prisma.$transaction([
@@ -78,7 +93,7 @@ export async function sendEvent(
where: { id: pipelineId },
data: {
currentState: toState,
contextJson: JSON.stringify(newContext),
contextJson: JSON.stringify(newPersistedSnapshot),
},
}),
prisma.stateTransition.create({
@@ -131,13 +146,32 @@ export async function getPipelineState(
if (!pipeline) return null;
const snap = JSON.parse(pipeline.contextJson) as { context?: PipelineContext };
const context =
(snap.context as PipelineContext | undefined) ??
(snap as unknown as PipelineContext);
return {
state: pipeline.currentState as PipelineState,
context: JSON.parse(pipeline.contextJson) as PipelineContext,
context,
transitions: pipeline.transitions,
};
}
/**
* Merge our canonical PipelineContext into the XState persisted snapshot.
* XState v5 snapshots include `.context`, so we overlay our values.
*/
function mergeContextIntoSnapshot(
snapshot: unknown,
ctx: PipelineContext,
): unknown {
if (snapshot && typeof snapshot === "object") {
return { ...(snapshot as object), context: ctx };
}
return snapshot;
}
export async function listPipelines(opts?: {
state?: PipelineState;
limit?: number;