Skip to content

Commit

Permalink
feat(preview): add experimental support for observing full documents
Browse files Browse the repository at this point in the history
  • Loading branch information
bjoerge committed Aug 27, 2024
1 parent 14530aa commit 49f1a58
Show file tree
Hide file tree
Showing 4 changed files with 153 additions and 1 deletion.
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
89 changes: 89 additions & 0 deletions packages/sanity/src/core/preview/createObserveDocument.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
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 function createObserveDocument({
mutationChannel,
client,
}: {
client: SanityClient
mutationChannel: Observable<WelcomeEvent | MutationEvent>
}) {
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: MutationEvent) {
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
}

0 comments on commit 49f1a58

Please sign in to comment.