UNPKG

sanity

Version:

Sanity is a real-time content infrastructure with a scalable, hosted backend featuring a Graph Oriented Query Language (GROQ), asset pipelines and fast edge caches

197 lines (178 loc) • 6.42 kB
import {type SanityClient} from '@sanity/client' import {isReference, type Schema, type ValidationMarker} from '@sanity/types' import {reduce as reduceJSON} from 'json-reduce' import {omit} from 'lodash' import { asyncScheduler, combineLatest, concat, defer, from, lastValueFrom, type Observable, of, timer, } from 'rxjs' import { distinct, distinctUntilChanged, first, groupBy, map, mergeMap, scan, shareReplay, skip, throttleTime, } from 'rxjs/operators' import {exhaustMapWithTrailing} from 'rxjs-exhaustmap-with-trailing' import shallowEquals from 'shallow-equals' import {type SourceClientOptions} from '../../../../config' import {type LocaleSource} from '../../../../i18n' import {type DraftsModelDocumentAvailability} from '../../../../preview' import {validateDocumentObservable, type ValidationContext} from '../../../../validation' import {type IdPair} from '../types' import {memoize} from '../utils/createMemoizer' import {editState} from './editState' /** * @hidden * @beta */ export interface ValidationStatus { isValidating: boolean validation: ValidationMarker[] revision?: string } const INITIAL_VALIDATION_STATUS: ValidationStatus = { isValidating: true, validation: [], } function findReferenceIds(obj: any): Set<string> { return reduceJSON( obj, (acc, node) => { if (isReference(node)) { acc.add(node._ref) } return acc }, new Set<string>(), ) } const EMPTY_VALIDATION: ValidationMarker[] = [] type GetDocumentExists = NonNullable<ValidationContext['getDocumentExists']> type ObserveDocumentPairAvailability = (id: string) => Observable<DraftsModelDocumentAvailability> const listenDocumentExists = ( observeDocumentAvailability: ObserveDocumentPairAvailability, id: string, ): Observable<boolean> => observeDocumentAvailability(id).pipe(map(({published}) => published.available)) // throttle delay for document updates (i.e. time between responding to changes in the current document) const DOC_UPDATE_DELAY = 200 // throttle delay for referenced document updates (i.e. time between responding to changes in referenced documents) const REF_UPDATE_DELAY = 1000 function shareLatestWithRefCount<T>() { return shareReplay<T>({bufferSize: 1, refCount: true}) } /** @internal */ export const validation = memoize( ( ctx: { client: SanityClient getClient: (options: SourceClientOptions) => SanityClient observeDocumentPairAvailability: ObserveDocumentPairAvailability schema: Schema i18n: LocaleSource }, {draftId, publishedId}: IdPair, typeName: string, ): Observable<ValidationStatus> => { const document$ = editState(ctx, {draftId, publishedId}, typeName).pipe( map(({draft, published}) => draft || published), throttleTime(DOC_UPDATE_DELAY, asyncScheduler, {trailing: true}), distinctUntilChanged((prev, next) => { if (prev?._rev === next?._rev) { return true } // _rev and _updatedAt may change without other fields changing (due to a limitation in mutator) // so only pass on documents if _other_ attributes changes return shallowEquals(omit(prev, '_rev', '_updatedAt'), omit(next, '_rev', '_updatedAt')) }), shareLatestWithRefCount(), ) const referenceIds$ = document$.pipe( map((document) => findReferenceIds(document)), mergeMap((ids) => from(ids)), ) // Note: we only use this to trigger a re-run of validation when a referenced document is published/unpublished const referenceExistence$ = referenceIds$.pipe( groupBy((id) => id, {duration: () => timer(1000 * 60 * 30)}), mergeMap((id$) => id$.pipe( distinct(), mergeMap((id) => listenDocumentExists(ctx.observeDocumentPairAvailability, id).pipe( map( // eslint-disable-next-line max-nested-callbacks (result) => [id, result] as const, ), ), ), ), ), scan((acc: Record<string, boolean>, [id, result]): Record<string, boolean> => { if (acc[id] === result) { return acc } return {...acc, [id]: result} }, {}), distinctUntilChanged(shallowEquals), shareLatestWithRefCount(), ) // Provided to individual validation functions to support using existence of a weakly referenced document // as part of the validation rule (used by references in place) const getDocumentExists: GetDocumentExists = ({id}) => lastValueFrom( referenceExistence$.pipe( // If the id is not present as key in the `referenceExistence` map it means it's existence status // isn't yet loaded, so we want to wait until it is first((referenceExistence) => id in referenceExistence), map((referenceExistence) => referenceExistence[id]), ), ) const referenceDocumentUpdates$ = referenceExistence$.pipe( // we'll skip the first emission since the document already gets an initial validation pass // we're only interested in updates in referenced documents after that skip(1), throttleTime(REF_UPDATE_DELAY, asyncScheduler, {leading: true, trailing: true}), ) return combineLatest([document$, concat(of(null), referenceDocumentUpdates$)]).pipe( map(([document]) => document), exhaustMapWithTrailing((document) => { return defer(() => { if (!document?._type) { return of({validation: EMPTY_VALIDATION, isValidating: false}) } return concat( of({isValidating: true, revision: document._rev}), validateDocumentObservable({ document, getClient: ctx.getClient, getDocumentExists, i18n: ctx.i18n, schema: ctx.schema, environment: 'studio', }).pipe( map((validationMarkers) => ({validation: validationMarkers, isValidating: false})), ), ) }) }), scan((acc, next) => ({...acc, ...next}), INITIAL_VALIDATION_STATUS), shareLatestWithRefCount(), ) }, (ctx, idPair, typeName) => { const config = ctx.client.config() return `${config.dataset ?? ''}-${config.projectId ?? ''}-${idPair.publishedId}-${typeName}` }, )