@sanity/sdk
Version:
489 lines (454 loc) • 16.5 kB
text/typescript
import {type Action} from '@sanity/client'
import {getPublishedId} from '@sanity/client/csm'
import {jsonMatch} from '@sanity/json-match'
import {type SanityDocument} from 'groq'
import {type ExprNode} from 'groq-js'
import {
catchError,
concatMap,
distinctUntilChanged,
EMPTY,
filter,
first,
firstValueFrom,
groupBy,
map,
mergeMap,
Observable,
of,
pairwise,
startWith,
Subject,
switchMap,
tap,
throttle,
timer,
withLatestFrom,
} from 'rxjs'
import {getClientState} from '../client/clientStore'
import {type DocumentHandle} from '../config/sanityConfig'
import {
bindActionByDataset,
type BoundDatasetKey,
type StoreAction,
} from '../store/createActionBinder'
import {type SanityInstance} from '../store/createSanityInstance'
import {createStateSourceAction, type StateSource} from '../store/createStateSourceAction'
import {defineStore, type StoreContext} from '../store/defineStore'
import {getDraftId} from '../utils/ids'
import {type DocumentAction} from './actions'
import {API_VERSION, INITIAL_OUTGOING_THROTTLE_TIME} from './documentConstants'
import {type DocumentEvent, getDocumentEvents} from './events'
import {listen, OutOfSyncError} from './listen'
import {type JsonMatch} from './patchOperations'
import {calculatePermissions, createGrantsLookup, type DatasetAcl, type Grant} from './permissions'
import {ActionError} from './processActions'
import {
type AppliedTransaction,
applyFirstQueuedTransaction,
applyRemoteDocument,
cleanupOutgoingTransaction,
getDocumentIdsFromActions,
manageSubscriberIds,
type OutgoingTransaction,
type QueuedTransaction,
removeQueuedTransaction,
revertOutgoingTransaction,
transitionAppliedTransactionsToOutgoing,
type UnverifiedDocumentRevision,
} from './reducers'
import {createFetchDocument, createSharedListener, type SharedListener} from './sharedListener'
export interface DocumentStoreState {
documentStates: {[TDocumentId in string]?: DocumentState}
queued: QueuedTransaction[]
applied: AppliedTransaction[]
outgoing?: OutgoingTransaction
grants?: Record<Grant, ExprNode>
error?: unknown
sharedListener: SharedListener
fetchDocument: (documentId: string) => Observable<SanityDocument | null>
events: Subject<DocumentEvent>
}
export interface DocumentState {
id: string
/**
* the "remote" local copy that matches the server. represents the last known
* server state. this gets updated every time we confirm remote patches
*/
remote?: SanityDocument | null
/**
* the current ephemeral working copy that includes local optimistic changes
* that have not yet been confirmed by the server
*/
local?: SanityDocument | null
/**
* the revision that our remote document is at
*/
remoteRev?: string | null
/**
* Array of subscription IDs. This document state will be deleted if there are
* no subscribers.
*/
subscriptions: string[]
/**
* An object keyed by transaction ID of revisions sent out but that have not
* yet been verified yet. When an applied transaction is transitioned to an
* outgoing transaction, it also adds unverified revisions for each document
* that is part of that outgoing transaction. Transactions are submitted to
* the server with a locally generated transaction ID. This way we can observe
* when our transaction comes back through the shared listener. Each listener
* event that comes back contains a `previousRev`. If we see our own
* transaction with a different `previousRev` than expected, we can rebase our
* local transactions on top of this new remote.
*/
unverifiedRevisions?: {[TTransactionId in string]?: UnverifiedDocumentRevision}
}
export const documentStore = defineStore<DocumentStoreState, BoundDatasetKey>({
name: 'Document',
getInitialState: (instance) => ({
documentStates: {},
// these can be emptied on refetch
queued: [],
applied: [],
sharedListener: createSharedListener(instance),
fetchDocument: createFetchDocument(instance),
events: new Subject(),
}),
initialize(context) {
const {sharedListener} = context.state.get()
const subscriptions = [
subscribeToQueuedAndApplyNextTransaction(context),
subscribeToSubscriptionsAndListenToDocuments(context),
subscribeToAppliedAndSubmitNextTransaction(context),
subscribeToClientAndFetchDatasetAcl(context),
]
return () => {
sharedListener.dispose()
subscriptions.forEach((subscription) => subscription.unsubscribe())
}
},
})
/**
* @beta
* Options for specifying a document and optionally a path within it.
*/
export interface DocumentOptions<
TPath extends string | undefined = undefined,
TDocumentType extends string = string,
TDataset extends string = string,
TProjectId extends string = string,
> extends DocumentHandle<TDocumentType, TDataset, TProjectId> {
path?: TPath
}
/** @beta */
export function getDocumentState<
TDocumentType extends string = string,
TDataset extends string = string,
TProjectId extends string = string,
>(
instance: SanityInstance,
options: DocumentOptions<undefined, TDocumentType, TDataset, TProjectId>,
): StateSource<SanityDocument<TDocumentType, `${TProjectId}.${TDataset}`> | undefined | null>
/** @beta */
export function getDocumentState<
TPath extends string = string,
TDocumentType extends string = string,
TDataset extends string = string,
TProjectId extends string = string,
>(
instance: SanityInstance,
options: DocumentOptions<TPath, TDocumentType, TDataset, TProjectId>,
): StateSource<
JsonMatch<SanityDocument<TDocumentType, `${TProjectId}.${TDataset}`>, TPath> | undefined
>
/** @beta */
export function getDocumentState<TData>(
instance: SanityInstance,
options: DocumentOptions<string | undefined>,
): StateSource<TData | undefined | null>
/** @beta */
export function getDocumentState(
...args: Parameters<typeof _getDocumentState>
): StateSource<unknown> {
return _getDocumentState(...args)
}
const _getDocumentState = bindActionByDataset(
documentStore,
createStateSourceAction({
selector: ({state: {error, documentStates}}, options: DocumentOptions<string | undefined>) => {
const {documentId, path, liveEdit} = options
if (error) throw error
if (liveEdit) {
// For liveEdit documents, only look at the single document
const document = documentStates[documentId]?.local
if (document === undefined) return undefined
if (!path) return document
const result = jsonMatch(document, path).next()
if (result.done) return undefined
const {value} = result.value
return value
}
// Standard draft/published logic
const draftId = getDraftId(documentId)
const publishedId = getPublishedId(documentId)
const draft = documentStates[draftId]?.local
const published = documentStates[publishedId]?.local
// wait for draft and published to be loaded before returning a value
if (draft === undefined || published === undefined) return undefined
const document = draft ?? published
if (!path) return document
const result = jsonMatch(document, path).next()
if (result.done) return undefined
const {value} = result.value
return value
},
onSubscribe: (context, options: DocumentOptions<string | undefined>) =>
manageSubscriberIds(context, options.documentId, {expandDraftPublished: !options.liveEdit}),
}),
)
/** @beta */
export function resolveDocument<
TDocumentType extends string = string,
TDataset extends string = string,
TProjectId extends string = string,
>(
instance: SanityInstance,
docHandle: DocumentHandle<TDocumentType, TDataset, TProjectId>,
): Promise<SanityDocument<TDocumentType, `${TProjectId}.${TDataset}`> | null>
/** @beta */
export function resolveDocument<TData extends SanityDocument>(
instance: SanityInstance,
docHandle: DocumentHandle<string, string, string>,
): Promise<TData | null>
/** @beta */
export function resolveDocument(
...args: Parameters<typeof _resolveDocument>
): Promise<SanityDocument | null> {
return _resolveDocument(...args)
}
const _resolveDocument = bindActionByDataset(
documentStore,
({instance}, docHandle: DocumentHandle<string, string, string>) => {
return firstValueFrom(
getDocumentState(instance, {
...docHandle,
path: undefined,
}).observable.pipe(filter((i) => i !== undefined)),
) as Promise<SanityDocument | null>
},
)
/** @beta */
export const getDocumentSyncStatus = bindActionByDataset(
documentStore,
createStateSourceAction({
selector: (
{state: {error, documentStates: documents, outgoing, applied, queued}},
doc: DocumentHandle,
) => {
const documentId = typeof doc === 'string' ? doc : doc.documentId
if (error) throw error
if (doc.liveEdit) {
// For liveEdit documents, only check the single document
const document = documents[documentId]
if (document === undefined) return undefined
return !queued.length && !applied.length && !outgoing
}
// Standard draft/published logic
const draftId = getDraftId(documentId)
const publishedId = getPublishedId(documentId)
const draft = documents[draftId]
const published = documents[publishedId]
if (draft === undefined || published === undefined) return undefined
return !queued.length && !applied.length && !outgoing
},
onSubscribe: (context, doc: DocumentHandle) => manageSubscriberIds(context, doc.documentId),
}),
)
type PermissionsStateOptions = {
actions: DocumentAction[]
}
/** @beta */
export const getPermissionsState = bindActionByDataset(
documentStore,
createStateSourceAction({
selector: calculatePermissions,
onSubscribe: (context, {actions}: PermissionsStateOptions) =>
manageSubscriberIds(context, getDocumentIdsFromActions(actions)),
}) as StoreAction<
DocumentStoreState,
[PermissionsStateOptions],
StateSource<ReturnType<typeof calculatePermissions>>
>,
)
/** @beta */
export const resolvePermissions = bindActionByDataset(
documentStore,
({instance}, options: PermissionsStateOptions) => {
return firstValueFrom(
getPermissionsState(instance, options).observable.pipe(filter((i) => i !== undefined)),
)
},
)
/** @beta */
export const subscribeDocumentEvents = bindActionByDataset(
documentStore,
({state}, eventHandler: (e: DocumentEvent) => void) => {
const {events} = state.get()
const subscription = events.subscribe(eventHandler)
return () => subscription.unsubscribe()
},
)
const subscribeToQueuedAndApplyNextTransaction = ({state}: StoreContext<DocumentStoreState>) => {
const {events} = state.get()
return state.observable
.pipe(
map(applyFirstQueuedTransaction),
distinctUntilChanged(),
tap((next) => state.set('applyFirstQueuedTransaction', next)),
catchError((error, caught) => {
if (error instanceof ActionError) {
state.set('removeQueuedTransaction', (prev) =>
removeQueuedTransaction(prev, error.transactionId),
)
events.next({
type: 'error',
message: error.message,
documentId: error.documentId,
transactionId: error.transactionId,
error,
})
return caught
}
throw error
}),
)
.subscribe({error: (error) => state.set('setError', {error})})
}
const subscribeToAppliedAndSubmitNextTransaction = ({
state,
instance,
}: StoreContext<DocumentStoreState>) => {
const {events} = state.get()
return state.observable
.pipe(
throttle(
(s) =>
// if there is no outgoing transaction, we can throttle by the
// initial outgoing throttle time…
!s.outgoing
? timer(INITIAL_OUTGOING_THROTTLE_TIME)
: // …otherwise, wait until the outgoing has been cleared
state.observable.pipe(first(({outgoing}) => !outgoing)),
{leading: false, trailing: true},
),
map(transitionAppliedTransactionsToOutgoing),
distinctUntilChanged((a, b) => a.outgoing?.transactionId === b.outgoing?.transactionId),
tap((next) => state.set('transitionAppliedTransactionsToOutgoing', next)),
map((s) => s.outgoing),
distinctUntilChanged(),
withLatestFrom(getClientState(instance, {apiVersion: API_VERSION}).observable),
concatMap(([outgoing, client]) => {
if (!outgoing) return EMPTY
return client.observable
.action(outgoing.outgoingActions as Action[], {
transactionId: outgoing.transactionId,
skipCrossDatasetReferenceValidation: true,
})
.pipe(
catchError((error) => {
state.set('revertOutgoingTransaction', revertOutgoingTransaction)
events.next({type: 'reverted', message: error.message, outgoing, error})
return EMPTY
}),
map((result) => ({result, outgoing})),
)
}),
tap(({outgoing, result}) => {
state.set('cleanupOutgoingTransaction', cleanupOutgoingTransaction)
for (const e of getDocumentEvents(outgoing)) events.next(e)
events.next({type: 'accepted', outgoing, result})
}),
)
.subscribe({error: (error) => state.set('setError', {error})})
}
const subscribeToSubscriptionsAndListenToDocuments = (
context: StoreContext<DocumentStoreState>,
) => {
const {state} = context
const {events} = state.get()
return state.observable
.pipe(
filter((s) => !!s.grants),
map((s) => Object.keys(s.documentStates)),
distinctUntilChanged((curr, next) => {
if (curr.length !== next.length) return false
const currSet = new Set(curr)
return next.every((i) => currSet.has(i))
}),
startWith(new Set<string>()),
pairwise(),
switchMap((pair) => {
const [curr, next] = pair.map((ids) => new Set(ids))
const added = Array.from(next).filter((i) => !curr.has(i))
const removed = Array.from(curr).filter((i) => !next.has(i))
// NOTE: the order of which these go out is somewhat important
// because that determines the order `applyRemoteDocument` is called
// which in turn determines which document version get populated
// first. because we prefer drafts, it's better to have those go out
// first so that the published document doesn't flash for a frame
const changes = [
...added.map((id) => ({id, add: true})),
...removed.map((id) => ({id, add: false})),
].sort((a, b) => {
const aIsDraft = a.id === getDraftId(a.id)
const bIsDraft = b.id === getDraftId(b.id)
if (aIsDraft && bIsDraft) return a.id.localeCompare(b.id, 'en-US')
if (aIsDraft) return -1
if (bIsDraft) return 1
return a.id.localeCompare(b.id, 'en-US')
})
return of<{id: string; add: boolean}[]>(...changes)
}),
groupBy((i) => i.id),
mergeMap((group) =>
group.pipe(
switchMap((e) => {
if (!e.add) return EMPTY
return listen(context, e.id).pipe(
catchError((error) => {
// retry on `OutOfSyncError`
if (error instanceof OutOfSyncError) listen(context, e.id)
throw error
}),
tap((remote) =>
state.set('applyRemoteDocument', (prev) =>
applyRemoteDocument(prev, remote, events),
),
),
)
}),
),
),
)
.subscribe({error: (error) => state.set('setError', {error})})
}
const subscribeToClientAndFetchDatasetAcl = ({
instance,
state,
key: {projectId, dataset},
}: StoreContext<DocumentStoreState, BoundDatasetKey>) => {
return getClientState(instance, {apiVersion: API_VERSION})
.observable.pipe(
switchMap((client) =>
client.observable.request<DatasetAcl>({
uri: `/projects/${projectId}/datasets/${dataset}/acl`,
tag: 'acl.get',
withCredentials: true,
}),
),
tap((datasetAcl) => state.set('setGrants', {grants: createGrantsLookup(datasetAcl)})),
)
.subscribe({
error: (error) => state.set('setError', {error}),
})
}