// Change history and undo (#49): zod-validated shapes for /gardens/:id/history // and /change-sets/:id/revert, plus the hooks the editor's History tab uses. // // A revert is a real write that can partly succeed, so its mutation is unusual: // a 409 is not a plain failure. It carries the change set that DID apply // alongside the entities it deliberately left alone, and the caller is expected // to report both. import { useState } from 'react' import { useInfiniteQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { z } from 'zod' import { ApiError, api, errorMessage } from './api' import { gardenFullKey } from './objects' export const changeSourceSchema = z.enum(['ui', 'agent', 'api']) export type ChangeSource = z.infer export const changeCountSchema = z.object({ entityType: z.enum(['garden', 'object', 'planting']), op: z.enum(['create', 'update', 'delete']), // COUNT(*) can only ever be a non-negative integer; constraining it means bad // data fails loudly rather than quietly hiding an Undo button (totalChanges // gates it) or rendering "1.5 beds changed". n: z.number().int().nonnegative(), }) export type ChangeCount = z.infer export const changeSetSchema = z.object({ id: z.number(), gardenId: z.number(), actorId: z.number(), actorName: z.string().default(''), source: changeSourceSchema, summary: z.string(), agentRunId: z.string().optional(), // Set when this change set reverts another. revertsId: z.number().optional(), // Set when another change set reverted this one. revertedById: z.number().optional(), counts: z.array(changeCountSchema).default([]), createdAt: z.string(), }) export type ChangeSet = z.infer export const revertConflictSchema = z.object({ entityType: z.enum(['garden', 'object', 'planting']), entityId: z.number(), reason: z.enum(['changed', 'missing', 'exists', 'unsupported']), name: z.string().optional(), }) export type RevertConflict = z.infer const historyPageSchema = z.object({ changeSets: z.array(changeSetSchema), hasMore: z.boolean(), }) const revertResultSchema = z.object({ changeSet: changeSetSchema.nullable(), conflicts: z.array(revertConflictSchema).default([]), }) export type RevertResult = z.infer const PAGE_SIZE = 30 export function historyKey(gardenId: number) { return ['gardens', gardenId, 'history'] as const } /** A garden's change sets, newest first, paged on demand. */ export function useGardenHistory(gardenId: number, enabled = true) { return useInfiniteQuery({ queryKey: historyKey(gardenId), enabled, initialPageParam: 0, queryFn: async ({ pageParam }) => historyPageSchema.parse( await api.get(`/gardens/${gardenId}/history`, { params: { limit: PAGE_SIZE, offset: pageParam } }), ), // Offset paging, not cursor: history is append-only at the FRONT, so a new // entry arriving mid-scroll shifts the window by one. That's a duplicate row // at worst, and the alternative (a cursor on created_at) buys little for a // list you scroll a page or two of. getNextPageParam: (last, pages) => (last.hasMore ? pages.length * PAGE_SIZE : undefined), }) } /** The conflicts carried by a 409 from a revert, or null if this isn't one. */ export function revertConflicts(err: unknown): RevertResult | null { if (!(err instanceof ApiError) || err.status !== 409) return null const parsed = revertResultSchema.safeParse(err.body) return parsed.success ? parsed.data : null } /** * Undo a change set. Invalidates the editor payload so the canvas redraws, and * the history list so the new entry (and the "reverted" mark on its target) * appear — on failure too, because a 409 means part of it applied. */ export function useRevertChangeSet(gardenId: number) { const qc = useQueryClient() const refresh = () => { void qc.invalidateQueries({ queryKey: gardenFullKey(gardenId) }) void qc.invalidateQueries({ queryKey: historyKey(gardenId) }) } return useMutation({ mutationFn: async (changeSetId: number): Promise => revertResultSchema.parse(await api.post(`/change-sets/${changeSetId}/revert`, undefined)), onSuccess: refresh, onError: (err) => { if (revertConflicts(err)) refresh() // a partial revert still changed things }, }) } /** How many rows a change set touched, for "3 changes" in the list. Undefined * counts total zero, which callers read as "no denominator to quote". */ export function totalChanges(cs: { counts?: ChangeCount[] }): number { return (cs.counts ?? []).reduce((sum, c) => sum + c.n, 0) } const ENTITY_NOUNS: Record = { garden: ['garden setting', 'garden settings'], object: ['bed', 'beds'], planting: ['planting', 'plantings'], } const OP_VERBS: Record = { create: 'added', update: 'changed', delete: 'removed', } /** * The change set's counts as a human phrase: "3 plantings added, 1 bed changed". * Returns '' when there's nothing to say, so callers can skip the line entirely * rather than render "0 changes". */ export function describeCounts(cs: ChangeSet): string { return cs.counts .filter((c) => c.n > 0) .map((c) => { const [one, many] = ENTITY_NOUNS[c.entityType] return `${c.n} ${c.n === 1 ? one : many} ${OP_VERBS[c.op]}` }) .join(', ') } /** What a revert left alone, phrased for the person who asked for the undo. */ export function describeConflict(c: RevertConflict): string { const noun = c.name ? `“${c.name}”` : `that ${ENTITY_NOUNS[c.entityType][0]}` switch (c.reason) { case 'changed': return `${noun} was edited since, so it was left alone` case 'missing': return `${noun} no longer exists` case 'exists': return `${noun} exists again, so it wasn't restored over the top` case 'unsupported': return `${noun} can't be undone automatically` } } /** * The one undo implementation, shared by the history list and (per #49) the * agent turn's inline Undo in #57 — so an undo behaves identically wherever it * is offered, including how it explains a partial result. * * Outcomes are keyed by change set id, so a single instance serves a whole list. */ export function useUndo(gardenId: number) { const revert = useRevertChangeSet(gardenId) const [outcomes, setOutcomes] = useState>({}) const undo = (cs: UndoTarget) => { setOutcomes((prev) => ({ ...prev, [cs.id]: { tone: 'pending', message: 'Undoing…' } })) revert.mutate(cs.id, { onSuccess: (result) => { setOutcomes((prev) => ({ ...prev, [cs.id]: describeUndo(cs, result) })) }, onError: (err) => { const result = revertConflicts(err) setOutcomes((prev) => ({ ...prev, [cs.id]: result ? describeUndo(cs, result) : { tone: 'error', message: errorMessage(err, "That couldn't be undone.") }, })) }, }) } return { undo, // Per change set rather than a single global flag, so one row's pending // state can't disable the others. outcomeFor: (id: number): UndoOutcome | undefined => outcomes[id], } } /** * What undo needs to know about a change set. * * `counts` is optional because the chat panel offers Undo on a turn knowing only * its change set id — the agent's reply carries the id, not the tally. Fabricating * counts to satisfy a type would produce a confidently wrong "1 of 1 changes * undone"; leaving them out lets describeUndo say what it actually knows. */ export interface UndoTarget { id: number counts?: ChangeCount[] } export interface UndoOutcome { tone: 'pending' | 'ok' | 'partial' | 'error' message: string } /** * What actually happened, said in full. A revert can partly succeed, and "2 of 3 * changes undone — “north bed” was edited since and was left alone" is the only * useful thing to report: a bare failure would be a lie about the two that did * apply, and a bare success would hide the one that didn't. */ export function describeUndo(target: UndoTarget, result: RevertResult): UndoOutcome { const skipped = result.conflicts.map(describeConflict).join('; ') // A NULL change set is the server's signal that nothing needed doing. An empty // `counts` is not the same thing and must not be read as one — that conflation // made a successful undo report "nothing left to undo", which is the worst // possible thing to tell someone about an action that just worked. const didSomething = result.changeSet != null const applied = result.changeSet ? totalChanges(result.changeSet) : 0 if (result.conflicts.length === 0) { // Reachable by undoing a creation whose object is already gone. if (!didSomething) return { tone: 'ok', message: 'Nothing left to undo — this was already reversed.' } return { tone: 'ok', message: 'Undone.' } } if (!didSomething) { return { tone: 'error', message: `Nothing was undone — ${skipped}.` } } // Only claim a denominator when we have one. "2 of 3" from a caller that // never knew the total would be a number invented to fill a sentence. const total = totalChanges(target) const scale = total > 0 && applied > 0 ? `${applied} of ${total} changes undone` : 'Partly undone' return { tone: 'partial', message: `${scale} — ${skipped}.` } }