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
text/typescript
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}`
},
)