diff --git a/apps/client/src/common/hooks-query/useLoadedRundown.ts b/apps/client/src/common/hooks-query/useLoadedRundown.ts index f1da2e6df..80f658e5d 100644 --- a/apps/client/src/common/hooks-query/useLoadedRundown.ts +++ b/apps/client/src/common/hooks-query/useLoadedRundown.ts @@ -23,7 +23,11 @@ export function useLoadedRundown() { export function useLoadedRundownWithMetadata() { const { data, status } = useLoadedRundown(); const selectedEventId = useSelectedEventId(); - const rundownMetadata = useMemo(() => getRundownMetadata(data, selectedEventId), [data, selectedEventId]); + const { entries, flatOrder } = data; + const rundownMetadata = useMemo( + () => getRundownMetadata({ entries, flatOrder }, selectedEventId), + [entries, flatOrder, selectedEventId], + ); return { data, status, rundownMetadata }; } @@ -36,7 +40,11 @@ export function useLoadedFlatRundown() { export function useLoadedFlatRundownWithMetadata() { const { data, status } = useLoadedRundown(); const selectedEventId = useSelectedEventId(); - const rundownWithMetadata = useMemo(() => getFlatRundownMetadata(data, selectedEventId), [data, selectedEventId]); + const { entries, flatOrder } = data; + const rundownWithMetadata = useMemo( + () => getFlatRundownMetadata({ entries, flatOrder }, selectedEventId), + [entries, flatOrder, selectedEventId], + ); return { data: rundownWithMetadata, status }; } diff --git a/apps/client/src/common/hooks-query/useRundown.ts b/apps/client/src/common/hooks-query/useRundown.ts index 51b84bcbe..cef300a65 100644 --- a/apps/client/src/common/hooks-query/useRundown.ts +++ b/apps/client/src/common/hooks-query/useRundown.ts @@ -27,7 +27,12 @@ export function useScopedSelectedEventId(): EntryId | null { export function useRundownWithMetadata() { const { data, status } = useRundown(); const selectedEventId = useScopedSelectedEventId(); - const rundownMetadata = useMemo(() => getRundownMetadata(data, selectedEventId), [data, selectedEventId]); + // key on the fields the derivation reads, a revision only change must not churn the list + const { entries, flatOrder } = data; + const rundownMetadata = useMemo( + () => getRundownMetadata({ entries, flatOrder }, selectedEventId), + [entries, flatOrder, selectedEventId], + ); return { data, status, rundownMetadata }; } @@ -46,7 +51,11 @@ export function useFlatRundownWithMetadata() { const { data, status } = useRundown(); const selectedEventId = useScopedSelectedEventId(); - const rundownWithMetadata = useMemo(() => getFlatRundownMetadata(data, selectedEventId), [data, selectedEventId]); + const { entries, flatOrder } = data; + const rundownWithMetadata = useMemo( + () => getFlatRundownMetadata({ entries, flatOrder }, selectedEventId), + [entries, flatOrder, selectedEventId], + ); return { data: rundownWithMetadata, status }; } diff --git a/apps/client/src/common/hooks/useEntryAction.ts b/apps/client/src/common/hooks/useEntryAction.ts index de5a667f6..7f785a172 100644 --- a/apps/client/src/common/hooks/useEntryAction.ts +++ b/apps/client/src/common/hooks/useEntryAction.ts @@ -31,6 +31,7 @@ import { swapEventData, } from 'ontime-utils'; import { useCallback, useMemo } from 'react'; +import isEqual from 'react-fast-compare'; import { moveDown, moveUp, orderEntries } from '../../features/rundown/rundown.utils'; import { getRundownCacheKey } from '../api/constants'; @@ -64,6 +65,22 @@ export type EventOptions = Partial<{ lastEventId: MaybeString; }>; +/** + * Applies a patch the way the server does, revision included. + * + * An entry revision advances on every change to that entry, which makes it a + * cheap marker for whether an entry has moved on. The optimistic entry has to + * carry the revision the server will return, otherwise the two disagree and the + * refetch resolves to a different object for no reason. + * Mirrors applyPatchToEntry in the rundown service: delays carry no revision. + */ +function patchEntry(entry: OntimeEntry, patch: Partial): OntimeEntry { + if (isOntimeEvent(entry) || isOntimeGroup(entry) || isOntimeMilestone(entry)) { + return { ...entry, ...patch, revision: entry.revision + 1 } as OntimeEntry; + } + return { ...entry, ...patch } as OntimeEntry; +} + type ClientInsertOptions = { after?: EntryId; before?: EntryId; @@ -321,8 +338,10 @@ function useEntryActionsForRundown(scopedRundownId: string) { if (previousData && eventId) { // optimistically update object const newRundown = { ...previousData.entries }; - // @ts-expect-error -- we expect the events to be of same type - newRundown[eventId] = { ...newRundown[eventId], ...newEvent }; + const previousEntry = newRundown[eventId]; + if (previousEntry) { + newRundown[eventId] = patchEntry(previousEntry, newEvent); + } queryClient.setQueryData(queryKey, { id: previousData.id, title: previousData.title, @@ -336,6 +355,23 @@ function useEntryActionsForRundown(scopedRundownId: string) { // Return a context with the previous and new events return { previousData, newEvent, queryKey }; }, + // the server is the authority on the applied patch, it may normalise what we sent + onSuccess: (response, _variables, context) => { + const serverEntry = response.data; + if (!serverEntry || !context?.queryKey) return; + + const cachedRundown = queryClient.getQueryData(context.queryKey); + if (!cachedRundown) return; + + // our optimistic entry usually describes the change exactly, writing an + // identical entry would discard the cached reference for nothing + if (isEqual(cachedRundown.entries[serverEntry.id], serverEntry)) return; + + queryClient.setQueryData(context.queryKey, { + ...cachedRundown, + entries: { ...cachedRundown.entries, [serverEntry.id]: serverEntry }, + }); + }, // Mutation fails, rollback undoes optimist update onError: (_error, _newEvent, context) => { if (context?.previousData && context?.queryKey) { @@ -508,10 +544,7 @@ function useEntryActionsForRundown(scopedRundownId: string) { if (Object.hasOwn(newRundown, eventId)) { const event = newRundown[eventId]; if (isOntimeEvent(event)) { - newRundown[eventId] = { - ...event, - ...data, - }; + newRundown[eventId] = patchEntry(event, data.data); } } }); diff --git a/apps/server/src/api-data/rundown/__tests__/rundown.dao.test.ts b/apps/server/src/api-data/rundown/__tests__/rundown.dao.test.ts index 0ef4fb7af..e1765f2cd 100644 --- a/apps/server/src/api-data/rundown/__tests__/rundown.dao.test.ts +++ b/apps/server/src/api-data/rundown/__tests__/rundown.dao.test.ts @@ -1718,16 +1718,18 @@ describe('rundownMutation.swap()', () => { expect((testRundown.entries['1'] as OntimeEvent).id).toBe('1'); expect((testRundown.entries['1'] as OntimeEvent).cue).toBe('data2'); expect((testRundown.entries['1'] as OntimeEvent).timeStart).toBe(1); - expect((testRundown.entries['1'] as OntimeEvent).revision).toBe(1); + // the swapped entries changed, their revision has to say so + expect((testRundown.entries['1'] as OntimeEvent).revision).toBe(2); expect((testRundown.entries['2'] as OntimeEvent).id).toBe('2'); expect((testRundown.entries['2'] as OntimeEvent).cue).toBe('data1'); expect((testRundown.entries['2'] as OntimeEvent).timeStart).toBe(2); - expect((testRundown.entries['2'] as OntimeEvent).revision).toBe(1); + expect((testRundown.entries['2'] as OntimeEvent).revision).toBe(2); expect((testRundown.entries['3'] as OntimeEvent).id).toBe('3'); expect((testRundown.entries['3'] as OntimeEvent).cue).toBe('data3'); expect((testRundown.entries['3'] as OntimeEvent).timeStart).toBe(3); + // untouched, so it keeps its revision expect((testRundown.entries['3'] as OntimeEvent).revision).toBe(1); }); }); diff --git a/apps/server/src/api-data/rundown/rundown.dao.ts b/apps/server/src/api-data/rundown/rundown.dao.ts index 52c3412b8..3f045cab7 100644 --- a/apps/server/src/api-data/rundown/rundown.dao.ts +++ b/apps/server/src/api-data/rundown/rundown.dao.ts @@ -422,7 +422,7 @@ function swap(rundown: Rundown, eventFrom: OntimeEvent, eventTo: OntimeEvent) { gap: eventFrom.gap, dayOffset: eventFrom.dayOffset, // keep revision number but increment it - revision: eventFrom.revision++, + revision: eventFrom.revision + 1, }; rundown.entries[eventTo.id] = { @@ -440,7 +440,7 @@ function swap(rundown: Rundown, eventFrom: OntimeEvent, eventTo: OntimeEvent) { gap: eventTo.gap, dayOffset: eventTo.dayOffset, // keep revision number but increment it - revision: eventTo.revision++, + revision: eventTo.revision + 1, }; } diff --git a/docs/agent-guides/domain-invariants.md b/docs/agent-guides/domain-invariants.md index 9418e4901..5aadf57e0 100644 --- a/docs/agent-guides/domain-invariants.md +++ b/docs/agent-guides/domain-invariants.md @@ -9,6 +9,10 @@ Load only for touched domains. Add only stable, recurring invariants; not one-of - Preserve entry identity and supported types across patch, clone, group, ungroup, reorder. - Distinguish loaded vs background rundown. Prefer explicit rundown ID over global current state. - No caller-owned rundown mutation unless explicitly contracted. +- Revisions mark change: a rundown revision advances on every commit, an entry revision on every + change to that entry. Delays carry no revision. Optimistic client updates apply the same bump the + server does, so an unchanged entry keeps its identity and clients can compare revisions to tell + what moved on. ## Persistence, realtime, cache