import type { MutationHistoryEntry, MutationTransactionLookupOutcome } from "@danypops/lector"; import { withWorkspace, workspaceForPath } from "../lector-client.ts"; import { invokeLectorVehicleOperation, type LectorVehicleCall } from "../vehicle-client.ts"; import { toWorkspaceRelativePath } from "../workspace-relative-path.ts"; const MAX_INTERNAL_HISTORY_LOOKUP_RESULTS = 2_000; export type MutationTransactionRevertOutcome = | { readonly status: "reverted"; readonly transactionId: string; readonly reverted: readonly { readonly path: string; readonly newHash: string | null }[]; } | { readonly status: "stale"; readonly transactionId: string; readonly stalePaths: readonly string[] } | Extract; /** Match MUTATION_HISTORY_READ_PERMISSIONS/MUTATION_HISTORY_WRITE_PERMISSIONS' own declared values server-side (mutation-history/operation-registration.ts). */ const MUTATION_HISTORY_READ_PERMISSIONS = ["workspace:read"]; const MUTATION_HISTORY_WRITE_PERMISSIONS = ["workspace:write"]; /** * Thin wrapper over Lector's mutation history, dispatched through invokeLectorVehicleOperation: * every successful edit is recorded, and any entry can be reverted -- guarded the same way every * other Lector write is. */ export interface MutationHistoryOperations { list(absolutePath: string, maxResults: number, call: LectorVehicleCall): Promise; revert(absolutePath: string, entryId: string, call: LectorVehicleCall): Promise<{ path: string; newHash: string | null }>; revertTransaction(absolutePath: string, transactionId: string, call: LectorVehicleCall): Promise; } async function listResolvedHistory( workspaceId: string, root: string, absolutePath: string, maxResults: number, call: LectorVehicleCall, ): Promise { const path = toWorkspaceRelativePath(root, absolutePath); const page = await invokeLectorVehicleOperation<{ entries: readonly MutationHistoryEntry[] }>( "workspace.mutationHistory", { workspaceId, path, maxResults }, MUTATION_HISTORY_READ_PERMISSIONS, call, ); return page.entries; } export function createMutationHistoryOperations(): MutationHistoryOperations { return { list(absolutePath, maxResults, call) { return withWorkspace( () => workspaceForPath(absolutePath), ({ workspaceId, root }) => listResolvedHistory(workspaceId, root, absolutePath, maxResults, call), ); }, revert(absolutePath, entryId, call) { return withWorkspace( () => workspaceForPath(absolutePath), async ({ workspaceId, root }) => { const entries = await listResolvedHistory(workspaceId, root, absolutePath, MAX_INTERNAL_HISTORY_LOOKUP_RESULTS, call); const target = entries.find((entry) => entry.id === entryId); if (!target) throw new Error(`mutation history entry "${entryId}" is not recorded for "${absolutePath}" -- list that path again before reverting`); if (target.transactionId !== null) { throw new Error( `mutation history entry "${entryId}" belongs to multi-file transaction "${target.transactionId}" -- refusing a partial revert; use action=revert-transaction with transactionId`, ); } return invokeLectorVehicleOperation<{ path: string; newHash: string | null }>( "workspace.revertMutation", { workspaceId, entryId }, MUTATION_HISTORY_WRITE_PERMISSIONS, call, ); }, ); }, revertTransaction(absolutePath, transactionId, call) { return withWorkspace( () => workspaceForPath(absolutePath), async ({ workspaceId }) => { const lookup = await invokeLectorVehicleOperation( "workspace.mutationTransaction", { workspaceId, transactionId }, MUTATION_HISTORY_READ_PERMISSIONS, call, ); if (lookup.status === "stale") return { status: "stale", transactionId, stalePaths: lookup.stalePaths }; if (lookup.status !== "ready") return lookup; const reverted = await invokeLectorVehicleOperation, "status">>( "workspace.revertMutationTransaction", { workspaceId, transactionId }, MUTATION_HISTORY_WRITE_PERMISSIONS, call, ); return { status: "reverted", ...reverted }; }, ); }, }; }