export const USER_ACTION_KINDS = ["IMPORT", "OPEN", "SAVE", "SAVE_AS", "EXPORT"] as const; export type UserActionKind = typeof USER_ACTION_KINDS[number]; export const USER_ACTION_STATUSES = ["IDLE", "RUNNING", "SUCCEEDED", "FAILED", "CANCELLED"] as const; export type UserActionStatus = typeof USER_ACTION_STATUSES[number]; export interface UserActionIdentity { actionId: string; kind: UserActionKind; } export interface UserActionState { kind: UserActionKind; status: UserActionStatus; identity: UserActionIdentity | null; errorCode: string | null; } export type UserActionEvent = | { type: "START"; identity: UserActionIdentity } | { type: "SUCCEED"; identity: UserActionIdentity } | { type: "FAIL"; identity: UserActionIdentity; errorCode: string } | { type: "CANCEL"; identity: UserActionIdentity; errorCode?: string } | { type: "RESET"; kind: UserActionKind }; export type UserActionTransitionErrorCode = | "USER_ACTION_KIND_MISMATCH" | "USER_ACTION_IDENTITY_MISMATCH" | "USER_ACTION_IDENTITY_REUSED" | "USER_ACTION_INVALID_ERROR_CODE" | "USER_ACTION_INVALID_TRANSITION"; export type UserActionTransitionResult = | { ok: true; state: UserActionState } | { ok: false; state: UserActionState; errorCode: UserActionTransitionErrorCode }; export type UserActionStates = Record; export const USER_ACTION_ALLOWED_TRANSITIONS: Readonly> = { IDLE: ["RUNNING"], RUNNING: ["SUCCEEDED", "FAILED", "CANCELLED"], SUCCEEDED: ["IDLE", "RUNNING"], FAILED: ["IDLE", "RUNNING"], CANCELLED: ["IDLE", "RUNNING"], }; function idleState(kind: UserActionKind): UserActionState { return { kind, status: "IDLE", identity: null, errorCode: null }; } export function createInitialUserActionStates(): UserActionStates { return Object.fromEntries(USER_ACTION_KINDS.map((kind) => [kind, idleState(kind)])) as UserActionStates; } function rejected(state: UserActionState, errorCode: UserActionTransitionErrorCode): UserActionTransitionResult { return { ok: false, state, errorCode }; } function isStableErrorCode(value: string): boolean { return /^[A-Z][A-Z0-9_]*$/.test(value); } export function transitionUserAction(state: UserActionState, event: UserActionEvent): UserActionTransitionResult { if (event.type === "RESET") { if (event.kind !== state.kind) return rejected(state, "USER_ACTION_KIND_MISMATCH"); if (!USER_ACTION_ALLOWED_TRANSITIONS[state.status].includes("IDLE")) { return rejected(state, "USER_ACTION_INVALID_TRANSITION"); } return { ok: true, state: idleState(state.kind) }; } if (event.identity.kind !== state.kind) return rejected(state, "USER_ACTION_KIND_MISMATCH"); if (!event.identity.actionId) return rejected(state, "USER_ACTION_IDENTITY_MISMATCH"); if (event.type === "START") { if (!USER_ACTION_ALLOWED_TRANSITIONS[state.status].includes("RUNNING")) { return rejected(state, "USER_ACTION_INVALID_TRANSITION"); } if (state.identity?.actionId === event.identity.actionId) { return rejected(state, "USER_ACTION_IDENTITY_REUSED"); } return { ok: true, state: { kind: state.kind, status: "RUNNING", identity: event.identity, errorCode: null }, }; } if (state.status !== "RUNNING") return rejected(state, "USER_ACTION_INVALID_TRANSITION"); if (state.identity?.actionId !== event.identity.actionId) { return rejected(state, "USER_ACTION_IDENTITY_MISMATCH"); } if (event.type === "SUCCEED") { return { ok: true, state: { ...state, status: "SUCCEEDED", errorCode: null } }; } const errorCode = event.type === "CANCEL" ? event.errorCode ?? "USER_ACTION_CANCELLED" : event.errorCode; if (!isStableErrorCode(errorCode)) return rejected(state, "USER_ACTION_INVALID_ERROR_CODE"); return { ok: true, state: { ...state, status: event.type === "CANCEL" ? "CANCELLED" : "FAILED", errorCode }, }; } export function reduceUserActionStates(states: UserActionStates, event: UserActionEvent): UserActionStates { const kind = event.type === "RESET" ? event.kind : event.identity.kind; const result = transitionUserAction(states[kind], event); return result.ok ? { ...states, [kind]: result.state } : states; }