import { createHash, randomUUID } from "node:crypto"; import { chmod, link, open, readFile, rename, unlink } from "node:fs/promises"; import { dirname, join } from "node:path"; export type WorkflowAtomicWritePoint = | "after-file-sync" | "before-commit" | "after-commit" | "after-directory-sync"; export type WorkflowAtomicFaultInjector = ( point: WorkflowAtomicWritePoint, ) => void | Promise; export async function writeWorkflowJson( path: string, value: unknown, options: { readonly createOnly?: boolean; readonly fault?: WorkflowAtomicFaultInjector; } = {}, ): Promise { const parent = dirname(path); const temporaryPath = join(parent, `.pi-amq-workflow-${randomUUID()}.tmp`); const contents = `${JSON.stringify(value)}\n`; let committed = false; try { const handle = await open(temporaryPath, "wx", 0o600); try { await handle.writeFile(contents, "utf8"); await handle.sync(); } finally { await handle.close(); } await chmod(temporaryPath, 0o600); await options.fault?.("after-file-sync"); await options.fault?.("before-commit"); if (options.createOnly) { await link(temporaryPath, path); committed = true; await unlink(temporaryPath); } else { await rename(temporaryPath, path); committed = true; } await options.fault?.("after-commit"); await syncWorkflowDirectory(parent); await options.fault?.("after-directory-sync"); } catch (error) { if (!committed) await removeFileIfPresent(temporaryPath); throw error; } } export async function readWorkflowJson(path: string): Promise { let contents: string; try { contents = await readFile(path, "utf8"); } catch (error) { if (isNodeError(error) && error.code === "ENOENT") return undefined; throw error; } return JSON.parse(contents) as unknown; } export async function syncWorkflowDirectory(path: string): Promise { let handle; try { handle = await open(path, "r"); await handle.sync(); } catch (error) { if (!isUnsupportedDirectorySync(error)) throw error; } finally { await handle?.close(); } } export function canonicalJson(value: unknown): string { return JSON.stringify(canonicalize(value)); } export function sha256(value: string): string { return `sha256:${createHash("sha256").update(value).digest("hex")}`; } function canonicalize(value: unknown): unknown { if (Array.isArray(value)) return value.map(canonicalize); if (value !== null && typeof value === "object") { return Object.fromEntries( Object.entries(value as Record) .sort(([left], [right]) => left.localeCompare(right)) .map(([key, child]) => [key, canonicalize(child)]), ); } if (value === undefined || typeof value === "function" || typeof value === "symbol") { throw new TypeError("Workflow state must be JSON serializable"); } if (typeof value === "number" && !Number.isFinite(value)) { throw new TypeError("Workflow state numbers must be finite"); } return value; } async function removeFileIfPresent(path: string): Promise { try { await unlink(path); } catch (error) { if (!isNodeError(error) || error.code !== "ENOENT") throw error; } } function isUnsupportedDirectorySync(error: unknown): boolean { return isNodeError(error) && (error.code === "EINVAL" || error.code === "ENOTSUP" || error.code === "EISDIR"); } export function isNodeError(error: unknown): error is NodeJS.ErrnoException { return error instanceof Error && "code" in error; }