import type { Selectable, Transaction } from "@tailor-platform/sdk/kysely"; import { ok, err, type Result } from "./result"; interface TransitionConfig { from: readonly TState[]; to: TState; } type TransitionsMap = Record>; type StateOf> = T[keyof T]["from"][number] | T[keyof T]["to"]; export interface Lifecycle> { readonly states: readonly [StateOf, ...StateOf[]]; readonly transitions: Readonly; tryTransition( currentState: StateOf, transition: K, ): T[K]["to"] | undefined; } /** Define a state machine from a transitions map. */ export function defineLifecycle>( transitions: T, ): Lifecycle { const stateSet = new Set(); for (const t of Object.values(transitions)) { for (const f of t.from) stateSet.add(f); stateSet.add(t.to); } return { states: [...stateSet] as [StateOf, ...StateOf[]], transitions, tryTransition(currentState, transition) { const t = transitions[transition]; if (!t) return undefined; return t.from.includes(currentState) ? t.to : undefined; }, }; } export interface TransitionErrors { notFound: new (id: string) => NF; invalidTransition: new (id: string) => IT; } /** Execute a status transition: fetch with forUpdate, validate, then update. */ export async function executeTransition< DB, TN extends keyof DB & string, SF extends keyof DB[TN] & string, T extends TransitionsMap, NF extends Error, IT extends Error, >(params: { db: Transaction; tableName: TN; statusField: SF; id: string; transition: keyof T & string; lifecycle: Lifecycle; errors: TransitionErrors; }): Promise, NF | IT>> { const { db, tableName, statusField, id, transition, lifecycle, errors } = params; // Kysely's conditional types don't resolve with generic type parameters // oxlint-disable-next-line typescript/no-explicit-any const trx = db as Transaction; const table = tableName as string; const field = statusField as string; const entity = await trx .selectFrom(table) .selectAll() .where("id", "=", id) .forUpdate() .executeTakeFirst(); if (!entity) { return err(new errors.notFound(id)); } const currentState = (entity as Record)[field] as StateOf; const targetState = lifecycle.tryTransition(currentState, transition); if (!targetState) { return err(new errors.invalidTransition(id)); } const updated = await trx .updateTable(table) .set({ [field]: targetState }) .where("id", "=", id) .returningAll() .executeTakeFirstOrThrow(); return ok(updated as Selectable); }