Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(preview): add experimental support for observing full documents #7397

Open
wants to merge 2 commits into
base: next
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import {describe, expect, it, jest} from '@jest/globals'
import {createClient, type WelcomeEvent} from '@sanity/client'
import {firstValueFrom, of, skip, Subject} from 'rxjs'
import {take} from 'rxjs/operators'

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,
})

jest
.spyOn(mockedClient.observable, 'fetch')
.mockImplementation(() => of([{_id: 'foo', fetched: true}]) as any)
jest.spyOn(mockedClient, 'withConfig').mockImplementation(() => mockedClient)

const mutationChannel = new Subject<WelcomeEvent | ListenerMutationEventLike>()

const observeDocument = createObserveDocument({
mutationChannel,
client: mockedClient,
})

const initial = firstValueFrom(observeDocument('foo').pipe(take(1)))

mutationChannel.next({type: 'welcome'})

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,
})

jest.spyOn(mockedClient.observable, 'fetch').mockImplementation(() => of([]) as any)
jest.spyOn(mockedClient, 'withConfig').mockImplementation(() => mockedClient)

const mutationChannel = new Subject<WelcomeEvent | ListenerMutationEventLike>()

const observeDocument = createObserveDocument({
mutationChannel,
client: mockedClient,
})

const initial = firstValueFrom(observeDocument('foo').pipe(take(1)))

mutationChannel.next({type: 'welcome'})

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,
})

jest.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,
)
jest.spyOn(mockedClient, 'withConfig').mockImplementation(() => mockedClient)

const mutationChannel = new Subject<WelcomeEvent | ListenerMutationEventLike>()

const observeDocument = createObserveDocument({
mutationChannel,
client: mockedClient,
})

const final = firstValueFrom(observeDocument('1c32390c').pipe(skip(1), take(1)))

mutationChannel.next({type: 'welcome'})
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',
})
})
})
1 change: 1 addition & 0 deletions packages/sanity/src/core/preview/createGlobalListener.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ export function createGlobalListener(client: SanityClient) {
includePreviousRevision: false,
includeMutations: false,
visibility: 'query',
effectFormat: 'mendoza',
tag: 'preview.global',
},
)
Expand Down
98 changes: 98 additions & 0 deletions packages/sanity/src/core/preview/createObserveDocument.ts
Original file line number Diff line number Diff line change
@@ -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<WelcomeEvent | ListenerMutationEventLike>
}) {
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<string, Observable<SanityDocument | undefined>> = {}

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},
)
}
20 changes: 19 additions & 1 deletion packages/sanity/src/core/preview/documentPreviewStore.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -56,6 +57,19 @@ export interface DocumentPreviewStore {
id: string,
paths: PreviewPath[],
) => Observable<DraftsModelDocument<T>>

/**
* Observe a complete document with the given ID
* @hidden
* @beta
*/
unstable_observeDocument: (id: string) => Observable<SanityDocument | undefined>
/**
* Observe a list of complete documents with the given IDs
* @hidden
* @beta
*/
unstable_observeDocuments: (ids: string[]) => Observable<(SanityDocument | undefined)[]>
}

/** @internal */
Expand All @@ -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})

Expand Down Expand Up @@ -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,
}
Expand Down
44 changes: 44 additions & 0 deletions packages/sanity/src/core/preview/utils/applyMendozaPatch.ts
Original file line number Diff line number Diff line change
@@ -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
}
Loading