/// import { type Cell, cell, Default, derive, handler, lift, recipe, } from "commontools"; type WorkflowStage = | "draft" | "in_review" | "approved" | "scheduled" | "published" | "archived"; interface WorkflowArgs { stage: Default; } interface TransitionEvent { target?: unknown; note?: unknown; } type TransitionResult = "accepted" | "rejected"; interface TransitionRecord { id: number; from: WorkflowStage; to: WorkflowStage; result: TransitionResult; note: string; reason: string; } const WORKFLOW_STAGES: readonly WorkflowStage[] = [ "draft", "in_review", "approved", "scheduled", "published", "archived", ] as const; const ALLOWED_TRANSITIONS: Record = { draft: ["in_review"], in_review: ["draft", "approved"], approved: ["scheduled", "draft"], scheduled: ["published", "draft"], published: ["archived"], archived: [], }; const isStage = (value: unknown): value is WorkflowStage => { if (typeof value !== "string") { return false; } return WORKFLOW_STAGES.includes(value as WorkflowStage); }; const sanitizeStage = (value: unknown): WorkflowStage => { if (isStage(value)) { return value; } return "draft"; }; const sanitizeNote = (value: unknown, fallback: string): string => { if (typeof value === "string") { const trimmed = value.trim(); if (trimmed.length > 0) { return trimmed; } } return fallback; }; const sanitizeSequence = (value: unknown): number => { if (typeof value === "number" && Number.isFinite(value)) { return Math.max(0, Math.floor(value)); } return 0; }; const recordTransition = ( history: Cell, sequence: Cell, entry: Omit, ) => { const existing = history.get(); const current = Array.isArray(existing) ? existing : []; const baseId = sanitizeSequence(sequence.get()); const next: TransitionRecord = { ...entry, id: baseId }; history.set([...current, next]); sequence.set(baseId + 1); }; const attemptStageTransition = handler( ( event: TransitionEvent | undefined, context: { stage: Cell; history: Cell; sequence: Cell; }, ) => { const current = sanitizeStage(context.stage.get()); const rawTarget = event?.target; const target = isStage(rawTarget) ? rawTarget : undefined; if (!target) { recordTransition(context.history, context.sequence, { from: current, to: current, result: "rejected", note: sanitizeNote( event?.note, `reject:${current}->invalid`, ), reason: "invalid-target", }); return; } if (target === current) { recordTransition(context.history, context.sequence, { from: current, to: current, result: "rejected", note: sanitizeNote( event?.note, `reject:${current}->${target}`, ), reason: "no-op", }); return; } const allowed = ALLOWED_TRANSITIONS[current] ?? []; if (!allowed.includes(target)) { recordTransition(context.history, context.sequence, { from: current, to: target, result: "rejected", note: sanitizeNote( event?.note, `reject:${current}->${target}`, ), reason: "not-allowed", }); return; } context.stage.set(target); recordTransition(context.history, context.sequence, { from: current, to: target, result: "accepted", note: sanitizeNote( event?.note, `accept:${current}->${target}`, ), reason: "transition", }); }, ); export const workflowStateMachine = recipe( "Workflow State Machine", ({ stage }) => { const transitions = cell([]); const sequence = cell(0); const normalizedStage = lift((value: WorkflowStage | undefined) => sanitizeStage(value) )(stage); const historyView = lift((entries: TransitionRecord[] | undefined) => Array.isArray(entries) ? entries.slice() : [] )(transitions); const attemptCount = lift((entries: TransitionRecord[]) => entries.length)( historyView, ); const acceptedCount = lift((entries: TransitionRecord[]) => entries.reduce( (count, entry) => count + (entry.result === "accepted" ? 1 : 0), 0, ) )(historyView); const rejectedCount = lift((entries: TransitionRecord[]) => entries.reduce( (count, entry) => count + (entry.result === "rejected" ? 1 : 0), 0, ) )(historyView); const stageIndex = lift((current: WorkflowStage) => WORKFLOW_STAGES.indexOf(current) )(normalizedStage); const availableTransitions = lift((current: WorkflowStage) => { const allowed = ALLOWED_TRANSITIONS[current] ?? []; return [...allowed]; })(normalizedStage); const availableLabel = derive( availableTransitions, (options) => (options.length === 0 ? "none" : options.join(",")), ); const stageMetadata = lift((current: WorkflowStage) => WORKFLOW_STAGES.map((stageName) => ({ stage: stageName, isCurrent: stageName === current, isReachable: (ALLOWED_TRANSITIONS[current] ?? []) .includes(stageName), })) )(normalizedStage); const lastTransitionStatus = lift((entries: TransitionRecord[]) => { const entry = entries.at(-1); if (!entry) { return "none"; } return `${entry.result}:${entry.from}->${entry.to}`; })(historyView); const summary = derive( { stage: normalizedStage, attempts: attemptCount, accepted: acceptedCount, rejected: rejectedCount, }, (snapshot) => `stage:${snapshot.stage} attempts:${snapshot.attempts}` + ` accepted:${snapshot.accepted} rejected:${snapshot.rejected}`, ); return { stage: normalizedStage, stageIndex, availableTransitions, availableLabel, stageMetadata, history: historyView, attemptCount, acceptedCount, rejectedCount, lastTransitionStatus, summary, transition: attemptStageTransition({ stage, history: transitions, sequence, }), }; }, ); export const pattern = workflowStateMachine;