diff --git a/packages/sanity/src/core/preview/__test__/createObserveDocument.test.ts b/packages/sanity/src/core/preview/__test__/createObserveDocument.test.ts new file mode 100644 index 00000000000..53136ea7550 --- /dev/null +++ b/packages/sanity/src/core/preview/__test__/createObserveDocument.test.ts @@ -0,0 +1,113 @@ +import {createClient, type WelcomeEvent} from '@sanity/client' +import {firstValueFrom, of, skip, Subject} from 'rxjs' +import {take} from 'rxjs/operators' +import {describe, expect, it, vi} from 'vitest' + +import {createObserveDocument, type ListenerMutationEventLike} from '../createObserveDocument' + +describe(createObserveDocument.name, () => { + it('fetches the current version of the document when receiving welcome event', async () => { + const mockedClient = createClient({ + projectId: 'abc', + dataset: 'production', + apiVersion: '2024-08-27', + useCdn: false, + }) + + vi.spyOn(mockedClient.observable, 'fetch').mockImplementation( + () => of([{_id: 'foo', fetched: true}]) as any, + ) + vi.spyOn(mockedClient, 'withConfig').mockImplementation(() => mockedClient) + + const mutationChannel = new Subject() + + const observeDocument = createObserveDocument({ + mutationChannel, + client: mockedClient, + }) + + const initial = firstValueFrom(observeDocument('foo').pipe(take(1))) + + mutationChannel.next({type: 'welcome', listenerName: 'preview.global'}) + + expect(await initial).toEqual({_id: 'foo', fetched: true}) + }) + + it('emits undefined if the document does not exist', async () => { + const mockedClient = createClient({ + projectId: 'abc', + dataset: 'production', + apiVersion: '2024-08-27', + useCdn: false, + }) + + vi.spyOn(mockedClient.observable, 'fetch').mockImplementation(() => of([]) as any) + vi.spyOn(mockedClient, 'withConfig').mockImplementation(() => mockedClient) + + const mutationChannel = new Subject() + + const observeDocument = createObserveDocument({ + mutationChannel, + client: mockedClient, + }) + + const initial = firstValueFrom(observeDocument('foo').pipe(take(1))) + + mutationChannel.next({type: 'welcome', listenerName: 'preview.global'}) + + expect(await initial).toEqual(undefined) + }) + + it('applies a mendoza patch when received over the listener', async () => { + const mockedClient = createClient({ + projectId: 'abc', + dataset: 'production', + apiVersion: '2024-08-27', + useCdn: false, + }) + + vi.spyOn(mockedClient.observable, 'fetch').mockImplementation( + () => + of([ + { + _createdAt: '2024-08-27T09:01:42Z', + _id: '1c32390c', + _rev: 'a8403810-81f7-49e6-8860-e52cb9111431', + _type: 'foo', + _updatedAt: '2024-08-27T09:03:38Z', + name: 'foo', + }, + ]) as any, + ) + vi.spyOn(mockedClient, 'withConfig').mockImplementation(() => mockedClient) + + const mutationChannel = new Subject() + + const observeDocument = createObserveDocument({ + mutationChannel, + client: mockedClient, + }) + + const final = firstValueFrom(observeDocument('1c32390c').pipe(skip(1), take(1))) + + mutationChannel.next({type: 'welcome', listenerName: 'preview.global'}) + mutationChannel.next({ + type: 'mutation', + documentId: '1c32390c', + previousRev: 'a8403810-81f7-49e6-8860-e52cb9111431', + resultRev: 'b3e7ebce-2bdd-4ab7-9056-b525773bd17a', + effects: { + apply: [11, 3, 23, 0, 18, 22, '8', 23, 19, 20, 15, 17, 'foos', 'name'], + }, + }) + + expect(await final).toEqual({ + _createdAt: '2024-08-27T09:01:42Z', + _id: '1c32390c', + _rev: 'b3e7ebce-2bdd-4ab7-9056-b525773bd17a', + _type: 'foo', + _updatedAt: '2024-08-27T09:03:38Z', + name: 'foos', + }) + }) +}) diff --git a/packages/sanity/src/core/preview/createGlobalListener.ts b/packages/sanity/src/core/preview/createGlobalListener.ts index ac76417b386..7be43f93e31 100644 --- a/packages/sanity/src/core/preview/createGlobalListener.ts +++ b/packages/sanity/src/core/preview/createGlobalListener.ts @@ -19,6 +19,7 @@ export function createGlobalListener(client: SanityClient) { includePreviousRevision: false, includeMutations: false, visibility: 'query', + effectFormat: 'mendoza', tag: 'preview.global', }, ) diff --git a/packages/sanity/src/core/preview/createObserveDocument.ts b/packages/sanity/src/core/preview/createObserveDocument.ts new file mode 100644 index 00000000000..947ebf13939 --- /dev/null +++ b/packages/sanity/src/core/preview/createObserveDocument.ts @@ -0,0 +1,98 @@ +import {type MutationEvent, type SanityClient, type WelcomeEvent} from '@sanity/client' +import {type SanityDocument} from '@sanity/types' +import {memoize, uniq} from 'lodash' +import {type RawPatch} from 'mendoza' +import {EMPTY, finalize, type Observable, of} from 'rxjs' +import {concatMap, map, scan, shareReplay} from 'rxjs/operators' + +import {type ApiConfig} from './types' +import {applyMutationEventEffects} from './utils/applyMendozaPatch' +import {debounceCollect} from './utils/debounceCollect' + +export type ListenerMutationEventLike = Pick< + MutationEvent, + 'type' | 'documentId' | 'previousRev' | 'resultRev' +> & { + effects?: { + apply: unknown[] + } +} + +export function createObserveDocument({ + mutationChannel, + client, +}: { + client: SanityClient + mutationChannel: Observable +}) { + const getBatchFetcher = memoize( + function getBatchFetcher(apiConfig: {dataset: string; projectId: string}) { + const _client = client.withConfig(apiConfig) + + function batchFetchDocuments(ids: [string][]) { + return _client.observable + .fetch(`*[_id in $ids]`, {ids: uniq(ids.flat())}, {tag: 'preview.observe-document'}) + .pipe( + // eslint-disable-next-line max-nested-callbacks + map((result) => ids.map(([id]) => result.find((r: {_id: string}) => r._id === id))), + ) + } + return debounceCollect(batchFetchDocuments, 100) + }, + (apiConfig) => apiConfig.dataset + apiConfig.projectId, + ) + + const MEMO: Record> = {} + + function observeDocument(id: string, apiConfig?: ApiConfig) { + const _apiConfig = apiConfig || { + dataset: client.config().dataset!, + projectId: client.config().projectId!, + } + const fetchDocument = getBatchFetcher(_apiConfig) + return mutationChannel.pipe( + concatMap((event) => { + if (event.type === 'welcome') { + return fetchDocument(id).pipe(map((document) => ({type: 'sync' as const, document}))) + } + return event.documentId === id ? of(event) : EMPTY + }), + scan((current: SanityDocument | undefined, event) => { + if (event.type === 'sync') { + return event.document + } + if (event.type === 'mutation') { + return applyMutationEvent(current, event) + } + //@ts-expect-error - this should never happen + throw new Error(`Unexpected event type: "${event.type}"`) + }, undefined), + ) + } + return function memoizedObserveDocument(id: string, apiConfig?: ApiConfig) { + const key = apiConfig ? `${id}-${JSON.stringify(apiConfig)}` : id + if (!(key in MEMO)) { + MEMO[key] = observeDocument(id, apiConfig).pipe( + finalize(() => delete MEMO[key]), + shareReplay({bufferSize: 1, refCount: true}), + ) + } + return MEMO[key] + } +} + +function applyMutationEvent(current: SanityDocument | undefined, event: ListenerMutationEventLike) { + if (event.previousRev !== current?._rev) { + console.warn('Document out of sync, skipping mutation') + return current + } + if (!event.effects) { + throw new Error( + 'Mutation event is missing effects. Is the listener set up with effectFormat=mendoza?', + ) + } + return applyMutationEventEffects( + current, + event as {effects: {apply: RawPatch}; previousRev: string; resultRev: string}, + ) +} diff --git a/packages/sanity/src/core/preview/documentPreviewStore.ts b/packages/sanity/src/core/preview/documentPreviewStore.ts index 154c7e56c26..487e1a27768 100644 --- a/packages/sanity/src/core/preview/documentPreviewStore.ts +++ b/packages/sanity/src/core/preview/documentPreviewStore.ts @@ -1,11 +1,12 @@ import {type MutationEvent, type SanityClient, type WelcomeEvent} from '@sanity/client' import {type PrepareViewOptions, type SanityDocument} from '@sanity/types' -import {type Observable} from 'rxjs' +import {combineLatest, type Observable} from 'rxjs' import {distinctUntilChanged, filter, map} from 'rxjs/operators' import {isRecord} from '../util' import {createPreviewAvailabilityObserver} from './availability' import {createGlobalListener} from './createGlobalListener' +import {createObserveDocument} from './createObserveDocument' import {createPathObserver} from './createPathObserver' import {createPreviewObserver} from './createPreviewObserver' import {createObservePathsDocumentPair} from './documentPair' @@ -56,6 +57,19 @@ export interface DocumentPreviewStore { id: string, paths: PreviewPath[], ) => Observable> + + /** + * Observe a complete document with the given ID + * @hidden + * @beta + */ + unstable_observeDocument: (id: string) => Observable + /** + * Observe a list of complete documents with the given IDs + * @hidden + * @beta + */ + unstable_observeDocuments: (ids: string[]) => Observable<(SanityDocument | undefined)[]> } /** @internal */ @@ -79,6 +93,7 @@ export function createDocumentPreviewStore({ map((event) => (event.type === 'welcome' ? {type: 'connected' as const} : event)), ) + const observeDocument = createObserveDocument({client, mutationChannel: globalListener}) const observeFields = createObserveFields({client: versionedClient, invalidationChannel}) const observePaths = createPathObserver({observeFields}) @@ -110,6 +125,9 @@ export function createDocumentPreviewStore({ observeForPreview, observeDocumentTypeFromId, + unstable_observeDocument: observeDocument, + unstable_observeDocuments: (ids: string[]) => + combineLatest(ids.map((id) => observeDocument(id))), unstable_observeDocumentPairAvailability: observeDocumentPairAvailability, unstable_observePathsDocumentPair: observePathsDocumentPair, } diff --git a/packages/sanity/src/core/preview/utils/applyMendozaPatch.ts b/packages/sanity/src/core/preview/utils/applyMendozaPatch.ts new file mode 100644 index 00000000000..9644eb60c33 --- /dev/null +++ b/packages/sanity/src/core/preview/utils/applyMendozaPatch.ts @@ -0,0 +1,44 @@ +import {type SanityDocument} from '@sanity/types' +import {applyPatch, type RawPatch} from 'mendoza' + +function omitRev(document: SanityDocument | undefined) { + if (document === undefined) { + return undefined + } + const {_rev, ...doc} = document + return doc +} + +/** + * + * @param document - The document to apply the patch to + * @param patch - The mendoza patch to apply + * @param baseRev - The revision of the document that the patch is calculated from. This is used to ensure that the patch is applied to the correct revision of the document + */ +export function applyMendozaPatch( + document: SanityDocument | undefined, + patch: RawPatch, + baseRev: string, +): SanityDocument | undefined { + if (baseRev !== document?._rev) { + throw new Error( + 'Invalid document revision. The provided patch is calculated from a different revision than the current document', + ) + } + const next = applyPatch(omitRev(document), patch) + return next === null ? undefined : next +} + +export function applyMutationEventEffects( + document: SanityDocument | undefined, + event: {effects: {apply: RawPatch}; previousRev: string; resultRev: string}, +) { + if (!event.effects) { + throw new Error( + 'Mutation event is missing effects. Is the listener set up with effectFormat=mendoza?', + ) + } + const next = applyMendozaPatch(document, event.effects.apply, event.previousRev) + // next will be undefined in case of deletion + return next ? {...next, _rev: event.resultRev} : undefined +}