@sanity/mutate
Version:
Experimental toolkit for working with Sanity mutations in JavaScript & TypeScript
1 lines • 107 kB
Source Map (JSON)
{"version":3,"file":"_unstable_store.cjs","sources":["../src/store/createReadOnlyStore.ts","../src/store/listeners/errors.ts","../src/store/listeners/utils/eventChainUtils.ts","../src/store/listeners/utils/sequentializeListenerEvents.ts","../src/store/listeners/createDocumentEventListener.ts","../src/store/utils/createDataLoader.ts","../src/store/listeners/createDocumentLoader.ts","../src/store/documentMap/applyDocumentMutation.ts","../src/store/documentMap/applyMendoza.ts","../src/store/utils/isEffectEvent.ts","../src/store/listeners/createDocumentUpdateListener.ts","../src/store/listeners/createIdSetListener.ts","../src/store/listeners/utils/shareReplayLatest.ts","../src/store/listeners/utils/withListenErrors.ts","../src/store/listeners/createSharedListener.ts","../src/store/utils/getMutationDocumentId.ts","../src/store/documentMap/applyMutations.ts","../src/store/documentMap/createDocumentMap.ts","../src/store/utils/createTransactionId.ts","../src/store/mock/createMockBackendAPI.ts","../src/store/optimistic/backend/createOptimisticStoreClientBackend.ts","../src/store/optimistic/backend/createOptimisticStoreMockBackend.ts","../src/store/documentMap/commit.ts","../src/store/utils/createReplayMemoizer.ts","../src/store/utils/filterMutationGroups.ts","../src/store/utils/arrayUtils.ts","../src/store/optimistic/optimizations/squashNodePatches.ts","../src/store/optimistic/optimizations/squashDMPStrings.ts","../src/store/utils/mergeMutationGroups.ts","../src/store/optimistic/optimizations/squashMutations.ts","../src/store/optimistic/rebase.ts","../src/store/optimistic/createOptimisticStore.ts"],"sourcesContent":["import {\n combineLatest,\n finalize,\n type Observable,\n ReplaySubject,\n share,\n timer,\n} from 'rxjs'\n\nimport {type SanityDocumentBase} from '../mutations/types'\nimport {\n type DocumentUpdate,\n type DocumentUpdateListener,\n} from './listeners/createDocumentUpdateListener'\n\nexport type MapTuple<T, U> = {[K in keyof T]: U}\n\nexport interface ReadOnlyDocumentStore {\n listenDocument: <Doc extends SanityDocumentBase>(\n id: string,\n ) => Observable<DocumentUpdate<Doc>>\n listenDocuments: <\n Doc extends SanityDocumentBase,\n const IdTuple extends string[],\n >(\n id: IdTuple,\n ) => Observable<MapTuple<IdTuple, DocumentUpdate<Doc>>>\n}\n\n/**\n * @param listenDocumentUpdates – a function that takes a document id and returns an observable of document snapshots\n * @param options\n */\nexport function createReadOnlyStore(\n listenDocumentUpdates: DocumentUpdateListener<SanityDocumentBase>,\n options: {shutdownDelay?: number} = {},\n): ReadOnlyDocumentStore {\n const cache = new Map<\n string,\n Observable<DocumentUpdate<SanityDocumentBase>>\n >()\n\n const {shutdownDelay} = options\n\n function listenDocument<Doc extends SanityDocumentBase>(id: string) {\n if (cache.has(id)) {\n return cache.get(id)! as Observable<DocumentUpdate<Doc>>\n }\n const cached = listenDocumentUpdates(id).pipe(\n finalize(() => cache.delete(id)),\n share({\n resetOnRefCountZero:\n typeof shutdownDelay === 'number' ? () => timer(shutdownDelay) : true,\n connector: () => new ReplaySubject(1),\n }),\n )\n cache.set(id, cached)\n return cached as Observable<DocumentUpdate<Doc>>\n }\n return {\n listenDocument,\n listenDocuments<Doc extends SanityDocumentBase, IdTuple extends string[]>(\n ids: IdTuple,\n ) {\n return combineLatest(\n ids.map(id => listenDocument<Doc>(id)),\n ) as Observable<MapTuple<IdTuple, DocumentUpdate<Doc>>>\n },\n }\n}\n","import {ClientError as SanityClientError} from '@sanity/client'\n\nimport {type ListenerSequenceState} from './utils/sequentializeListenerEvents'\n\n/*\n * This file should include all errors that can be thrown by the document listener\n */\n\nexport const ClientError = SanityClientError\n\nexport class FetchError extends Error {\n cause?: Error\n constructor(message: string, extra?: {cause?: Error}) {\n super(message)\n this.cause = extra?.cause\n this.name = 'FetchError'\n }\n}\n\nexport class PermissionDeniedError extends Error {\n cause?: Error\n constructor(message: string, extra?: {cause?: Error}) {\n super(message)\n this.cause = extra?.cause\n this.name = 'PermissionDeniedError'\n }\n}\n\nexport class ChannelError extends Error {\n constructor(message: string) {\n super(message)\n this.name = 'ChannelError'\n }\n}\n\nexport class DisconnectError extends Error {\n constructor(message: string) {\n super(message)\n this.name = 'DisconnectError'\n }\n}\n\nexport class OutOfSyncError extends Error {\n /**\n * Attach state to the error for debugging/reporting\n */\n state: ListenerSequenceState\n constructor(message: string, state: ListenerSequenceState) {\n super(message)\n this.name = 'OutOfSyncError'\n this.state = state\n }\n}\n\nexport class DeadlineExceededError extends OutOfSyncError {\n constructor(message: string, state: ListenerSequenceState) {\n super(message, state)\n this.name = 'DeadlineExceededError'\n }\n}\nexport class MaxBufferExceededError extends OutOfSyncError {\n constructor(message: string, state: ListenerSequenceState) {\n super(message, state)\n this.name = 'MaxBufferExceededError'\n }\n}\n\nexport function isClientError(e: unknown): e is SanityClientError {\n if (typeof e !== 'object') return false\n if (!e) return false\n return 'statusCode' in e && 'response' in e\n}\n","export function discardChainTo<T extends {resultRev?: string}>(\n chain: T[],\n revision: string | undefined,\n) {\n const revisionIndex = chain.findIndex(event => event.resultRev === revision)\n\n return split(chain, revisionIndex + 1)\n}\n\nfunction split<T>(array: T[], index: number): [T[], T[]] {\n if (index < 0) {\n return [[], array]\n }\n return [array.slice(0, index), array.slice(index)]\n}\n\nexport function toOrderedChains<\n T extends {previousRev?: string; resultRev?: string},\n>(events: T[]) {\n const parents: Record<string, T | undefined> = {}\n\n events.forEach(event => {\n parents[event.resultRev || 'undefined'] = events.find(\n other => other.resultRev === event.previousRev,\n )\n })\n\n // get entries without a parent (if there's more than one, we have a problem)\n const orphans = Object.entries(parents).filter(([, parent]) => {\n return !parent\n })!\n\n return orphans.map(orphan => {\n const [headRev] = orphan\n\n let current = events.find(event => event.resultRev === headRev)\n\n const sortedList: T[] = []\n while (current) {\n sortedList.push(current)\n\n current = events.find(event => event.previousRev === current?.resultRev)\n }\n return sortedList\n })\n}\n","import {partition as lodashPartition} from 'lodash'\nimport {concat, type Observable, of, switchMap, throwError, timer} from 'rxjs'\nimport {mergeMap, scan} from 'rxjs/operators'\n\nimport {type SanityDocumentBase} from '../../../mutations/types'\nimport {type ListenerEvent, type ListenerMutationEvent} from '../../types'\nimport {DeadlineExceededError, MaxBufferExceededError} from '../errors'\nimport {discardChainTo, toOrderedChains} from './eventChainUtils'\n\n/**\n * lodash types are not great\n * todo: replace with es-toolkit\n * @param array\n * @param predicate\n */\nfunction partition<T>(\n array: T[],\n predicate: (element: T) => boolean,\n): [trueValues: T[], falseValues: T[]] {\n return lodashPartition(array, predicate)\n}\nexport interface ListenerSequenceState {\n /**\n * Tracks the latest revision from the server that can be applied locally\n * Once we receive a mutation event that has a `previousRev` that equals `base.revision`\n * we will move `base.revision` to the event's `resultRev`\n * `base.revision` will be undefined if document doesn't exist.\n * `base` is `undefined` until the snapshot event is received\n */\n base: {revision: string | undefined} | undefined\n /**\n * Array of events to pass on to the stream, e.g. when mutation applies to current head revision, or a chain is complete\n */\n emitEvents: ListenerEvent[]\n /**\n * Buffer to keep track of events that doesn't line up in a [previousRev, resultRev] -- [previousRev, resultRev] sequence\n * This can happen if events arrive out of order, or if an event in the middle for some reason gets lost\n */\n buffer: ListenerMutationEvent[]\n}\n\nconst DEFAULT_MAX_BUFFER_SIZE = 20\nconst DEFAULT_DEADLINE_MS = 30000\n\nconst EMPTY_ARRAY: never[] = []\n\nexport interface SequentializeListenerEventsOptions {\n maxBufferSize?: number\n resolveChainDeadline?: number\n onDiscard?: (discarded: ListenerMutationEvent[]) => void\n onBrokenChain?: (discarded: ListenerMutationEvent[]) => void\n}\n\n/**\n * Takes an input observable of listener events that might arrive out of order, and emits them in sequence\n * If we receive mutation events that doesn't line up in [previousRev, resultRev] pairs we'll put them in a buffer and\n * check if we have an unbroken chain every time we receive a new event\n *\n * If the buffer grows beyond `maxBufferSize`, or if `resolveChainDeadline` milliseconds passes before the chain resolves\n * an OutOfSyncError will be thrown on the stream\n *\n * @internal\n */\nexport function sequentializeListenerEvents<Doc extends SanityDocumentBase>(\n options?: SequentializeListenerEventsOptions,\n) {\n const {\n resolveChainDeadline = DEFAULT_DEADLINE_MS,\n maxBufferSize = DEFAULT_MAX_BUFFER_SIZE,\n onDiscard,\n onBrokenChain,\n } = options || {}\n\n return (input$: Observable<ListenerEvent>): Observable<ListenerEvent> => {\n return input$.pipe(\n scan(\n (\n state: ListenerSequenceState,\n event: ListenerEvent,\n ): ListenerSequenceState => {\n if (event.type === 'mutation' && !state.base) {\n throw new Error(\n 'Invalid state. Cannot create a sequence without a base',\n )\n }\n if (event.type === 'sync') {\n // When receiving a new snapshot, we can safely discard the current orphaned and chainable buffers\n return {\n base: {revision: event.document?._rev},\n buffer: EMPTY_ARRAY,\n emitEvents: [event],\n }\n }\n\n if (event.type === 'mutation') {\n if (!event.resultRev && !event.previousRev) {\n throw new Error(\n 'Invalid mutation event: Events must have either resultRev or previousRev',\n )\n }\n // Note: the buffer may have multiple holes in it (this is a worst case scenario, and probably not likely, but still),\n // so we need to consider all possible chains\n // `toOrderedChains` will return all detected chains and each of the returned chains will be ordered\n // Once we have a list of chains, we can then discard any chain that leads up to the current revision\n // since they are already applied on the document\n const orderedChains = toOrderedChains(\n state.buffer.concat(event),\n ).map(chain => {\n // in case the chain leads up to the current revision\n const [discarded, rest] = discardChainTo(\n chain,\n state.base!.revision,\n )\n if (onDiscard && discarded.length > 0) {\n onDiscard(discarded)\n }\n return rest\n })\n\n const [applicableChains, _nextBuffer] = partition(\n orderedChains,\n chain => {\n // note: there can be at most one applicable chain\n return state.base!.revision === chain[0]?.previousRev\n },\n )\n\n const nextBuffer = _nextBuffer.flat()\n if (applicableChains.length > 1) {\n throw new Error('Expected at most one applicable chain')\n }\n if (\n applicableChains.length > 0 &&\n applicableChains[0]!.length > 0\n ) {\n // we now have a continuous chain that can apply on the base revision\n // Move current base revision to the last mutation event in the applicable chain\n const lastMutation = applicableChains[0]!.at(-1)!\n const nextBaseRevision =\n // special case: if the mutation deletes the document it technically has no revision, despite\n // resultRev pointing at a transaction id.\n lastMutation.transition === 'disappear'\n ? undefined\n : lastMutation?.resultRev\n return {\n base: {revision: nextBaseRevision},\n emitEvents: applicableChains[0]!,\n buffer: nextBuffer,\n }\n }\n\n if (nextBuffer.length >= maxBufferSize) {\n throw new MaxBufferExceededError(\n `Too many unchainable mutation events: ${state.buffer.length}`,\n state,\n )\n }\n return {\n ...state,\n buffer: nextBuffer,\n emitEvents: EMPTY_ARRAY,\n }\n }\n // Any other event (e.g. 'reconnect' is passed on verbatim)\n return {...state, emitEvents: [event]}\n },\n {\n emitEvents: EMPTY_ARRAY,\n base: undefined,\n buffer: EMPTY_ARRAY,\n },\n ),\n switchMap(state => {\n if (state.buffer.length > 0) {\n onBrokenChain?.(state.buffer)\n return concat(\n of(state),\n timer(resolveChainDeadline).pipe(\n mergeMap(() =>\n throwError(() => {\n return new DeadlineExceededError(\n `Did not resolve chain within a deadline of ${resolveChainDeadline}ms`,\n state,\n )\n }),\n ),\n ),\n )\n }\n return of(state)\n }),\n mergeMap(state => {\n // this will simply flatten the list of events into individual emissions\n // if the flushEvents array is empty, nothing will be emitted\n return state.emitEvents\n }),\n )\n }\n}\n","import {type ReconnectEvent, type WelcomeEvent} from '@sanity/client'\nimport {\n catchError,\n concatMap,\n EMPTY,\n map,\n type Observable,\n of,\n throwError,\n} from 'rxjs'\n\nimport {type SanityDocumentBase} from '../../mutations/types'\nimport {type ListenerMutationEvent, type ListenerSyncEvent} from '../types'\nimport {FetchError, isClientError, PermissionDeniedError} from './errors'\nimport {type DocumentLoader} from './types'\nimport {sequentializeListenerEvents} from './utils/sequentializeListenerEvents'\n\n/**\n * Creates a function that can be used to listen for events that happens in a single document\n * Features\n * - builtin retrying and connection recovery (track disconnected state by listening for `reconnect` events)\n * - builtin mutation event ordering (they might arrive out of order), lost events detection (/listen endpoint doesn't guarantee delivery) and recovery\n * - discards already-applied mutation events received while fetching the initial document snapshot\n * @param options\n */\nexport function createDocumentEventListener(options: {\n loadDocument: DocumentLoader\n listenerEvents: Observable<\n WelcomeEvent | ListenerMutationEvent | ReconnectEvent\n >\n}) {\n const {listenerEvents, loadDocument} = options\n\n return function listen<Doc extends SanityDocumentBase>(documentId: string) {\n return listenerEvents.pipe(\n concatMap(event => {\n if (event.type === 'mutation') {\n return event.documentId === documentId ? of(event) : EMPTY\n }\n\n if (event.type === 'reconnect') {\n return of(event)\n }\n\n if (event.type === 'welcome') {\n return loadDocument(documentId).pipe(\n catchError((err: unknown) => {\n const error = toError(err)\n if (isClientError(error)) {\n return throwError(() => error)\n }\n return throwError(\n () =>\n new FetchError(\n `An unexpected error occurred while fetching document: ${error?.message}`,\n {cause: error},\n ),\n )\n }),\n map(result => {\n if (result.accessible) {\n return result.document as Doc\n }\n if (result.reason === 'permission') {\n throw new PermissionDeniedError(\n `Permission denied. Make sure the current user (or token) has permission to read the document with ID=\"${documentId}\".`,\n )\n }\n return undefined\n }),\n map(\n (doc: undefined | Doc): ListenerSyncEvent<Doc> => ({\n type: 'sync',\n document: doc,\n }),\n ),\n )\n }\n // ignore unknown events\n return EMPTY\n }),\n sequentializeListenerEvents<Doc>({\n maxBufferSize: 10,\n resolveChainDeadline: 10_000,\n }),\n )\n }\n}\n\nfunction toError(maybeErr: unknown) {\n if (maybeErr instanceof Error) {\n return maybeErr\n }\n if (typeof maybeErr === 'object' && maybeErr) {\n return Object.assign(new Error(), maybeErr)\n }\n return new Error(String(maybeErr))\n}\n","import {\n asyncScheduler,\n BehaviorSubject,\n bufferWhen,\n concat,\n defer,\n EMPTY,\n filter,\n map,\n merge,\n mergeMap,\n Observable,\n of,\n scheduled,\n share,\n Subject,\n takeUntil,\n takeWhile,\n} from 'rxjs'\n\ntype AnyKey = keyof any\n\nconst defaultDurationSelector = () => scheduled(of(0), asyncScheduler)\n\ntype Request<T> = {key: T; cancelled: boolean}\n\nexport function createDataLoader<T, KeyT extends AnyKey>(options: {\n onLoad: (ids: KeyT[]) => Observable<T[]>\n durationSelector?: () => Observable<unknown>\n}) {\n const durationSelector = options.durationSelector || defaultDurationSelector\n\n const requests$ = new BehaviorSubject<Request<KeyT> | undefined>(undefined)\n const unsubscribes$ = new Subject<void>()\n\n const batchResponses = requests$.pipe(\n filter(req => !!req),\n bufferWhen(durationSelector),\n map(requests => requests.filter(request => !request.cancelled)),\n filter(requests => requests.length > 0),\n mergeMap(requests => {\n const keys = requests.map(request => request.key)\n\n const responses = options.onLoad(keys).pipe(\n takeUntil(\n unsubscribes$.pipe(\n filter(() => requests.every(request => request.cancelled)),\n ),\n ),\n mergeMap(batchResult =>\n requests.map((request, i) => ({\n type: 'value' as const,\n request,\n response: batchResult[i],\n })),\n ),\n )\n // we need to signal to subscribers that the request batch has ended\n const responseEnds = requests.map(request => ({\n request,\n type: 'complete' as const,\n }))\n return concat(responses, responseEnds)\n }),\n share(),\n )\n\n return (key: KeyT) => {\n return new Observable<T>(subscriber => {\n const mutableRequestState: Request<KeyT> = {key, cancelled: false}\n const emit = defer(() => {\n requests$.next(mutableRequestState)\n return EMPTY\n })\n const subscription = merge(\n batchResponses.pipe(\n filter(batchResult => batchResult.request === mutableRequestState),\n takeWhile(batchResult => batchResult.type !== 'complete'),\n map(batchResult => batchResult.response),\n ),\n emit,\n ).subscribe(subscriber)\n\n return () => {\n // note: will not be cancelled in-flight unless the whole batch is cancelled\n mutableRequestState.cancelled = true\n unsubscribes$.next()\n subscription.unsubscribe()\n }\n })\n }\n}\n","import {type SanityClient} from '@sanity/client'\nimport {keyBy} from 'lodash'\nimport {map, type Observable} from 'rxjs'\n\nimport {type SanityDocumentBase} from '../../mutations/types'\nimport {createDataLoader} from '../utils/createDataLoader'\nimport {type DocumentResult} from './types'\n\nexport type FetchDocuments = (ids: string[]) => Observable<DocEndpointResponse>\n\nexport interface OmittedDocument {\n id: string\n reason: 'existence' | 'permission'\n}\nexport interface DocEndpointResponse {\n documents: SanityDocumentBase[]\n omitted: OmittedDocument[]\n}\n\n/**\n * Creates a \"dataloader\" style document loader that fetches from the /doc endpoint\n * @param {FetchDocuments} fetchDocuments - The client instance used for fetching documents.\n * @param options\n */\nexport function createDocumentLoader(\n fetchDocuments: FetchDocuments,\n options?: {durationSelector?: () => Observable<unknown>; tag?: string},\n) {\n return createDataLoader({\n onLoad: (ids: string[]) => fetchDedupedWith(fetchDocuments, ids),\n durationSelector: options?.durationSelector,\n })\n}\n\nexport function createDocumentLoaderFromClient(\n client: SanityClient,\n options?: {durationSelector?: () => Observable<unknown>; tag?: string},\n) {\n const fetchDocument = (ids: string[]) => {\n const requestOptions = {\n uri: client.getDataUrl('doc', ids.join(',')),\n json: true,\n tag: options?.tag,\n }\n\n return client.observable.request<DocEndpointResponse>(requestOptions)\n }\n\n return createDocumentLoader(fetchDocument, options)\n}\n\n/**\n * Processes an array of document IDs:\n * 1. Extracts the set of unique IDs from the input array.\n * 2. Calls `fetchDocuments` with the unique IDs to retrieve their corresponding documents.\n * 3. Returns an array of documents, preserving the order and duplication of IDs in the input array.\n *\n * Example:\n * - Input: [a, a, b]\n * - `fetchDocuments` is called with ([a, b]), returning: [{_id: a}, {_id: b}]\n * - Output: [{_id: a}, {_id: a}, {_id: b}]\n *\n * @param {FetchDocuments} fetchDocuments - The client instance used for fetching documents.\n * @param {Array<string>} ids - An array of document IDs to process.\n * @returns {Observable<DocumentResult[]>} - An array of documents, mapped to the input IDs.\n */\nfunction fetchDedupedWith(fetchDocuments: FetchDocuments, ids: string[]) {\n const unique = [...new Set(ids)]\n return fetchDocuments(unique).pipe(\n map(results => prepareResponse(ids, results)),\n map(results => {\n const byId = keyBy(results, result => result.id)\n return ids.map(id => byId[id]!)\n }),\n )\n}\n\nfunction prepareResponse(\n requestedIds: string[],\n response: DocEndpointResponse,\n): DocumentResult[] {\n const documents = keyBy(response.documents, entry => entry._id!)\n const omitted = keyBy(response.omitted, entry => entry.id)\n return requestedIds.map(id => {\n if (documents[id]) {\n return {id, accessible: true, document: documents[id]!}\n }\n const omittedEntry = omitted[id]\n if (!omittedEntry) {\n // in case the document is missing and there's no entry for it in `omitted`\n // this should not normally happen, but if it does, handle it is as if the document doesn't exist\n return {id, accessible: false, reason: 'existence'}\n }\n if (omittedEntry.reason === 'permission') {\n return {\n id,\n accessible: false,\n reason: 'permission',\n }\n }\n // handle any unknown omitted reason as nonexistence too\n return {\n id,\n accessible: false,\n reason: 'existence',\n }\n })\n}\n","import {nanoid} from 'nanoid'\n\nimport {applyPatchMutation, assignId, hasId} from '../../apply'\nimport {\n type CreateIfNotExistsMutation,\n type CreateMutation,\n type CreateOrReplaceMutation,\n type DeleteMutation,\n type Mutation,\n type PatchMutation,\n type SanityDocumentBase,\n} from '../../mutations/types'\n\nexport type MutationResult<Doc extends SanityDocumentBase> =\n | {\n id: string\n status: 'created'\n after: Doc\n }\n | {\n id: string\n status: 'updated'\n before: Doc\n after: Doc\n }\n | {\n id: string\n status: 'deleted'\n before: Doc | undefined\n after: undefined\n }\n | {\n status: 'error'\n message: string\n }\n | {\n status: 'noop'\n }\n\n/**\n * Applies a set of mutations to the provided document\n * @param current\n * @param mutation\n */\nexport function applyAll<Doc extends SanityDocumentBase>(\n current: Doc | undefined,\n mutation: Mutation<Doc>[],\n): Doc | undefined {\n return mutation.reduce((doc, m) => {\n const res = applyDocumentMutation(doc, m)\n if (res.status === 'error') {\n throw new Error(res.message)\n }\n return res.status === 'noop' ? doc : res.after\n }, current)\n}\n\n/**\n * Applies a mutation to the provided document\n * @param document\n * @param mutation\n */\nexport function applyDocumentMutation<Doc extends SanityDocumentBase>(\n document: Doc | undefined,\n mutation: Mutation<Doc>,\n): MutationResult<Doc> {\n if (mutation.type === 'create') {\n return create(document, mutation)\n }\n if (mutation.type === 'createIfNotExists') {\n return createIfNotExists(document, mutation)\n }\n if (mutation.type === 'delete') {\n return del(document, mutation)\n }\n if (mutation.type === 'createOrReplace') {\n return createOrReplace(document, mutation)\n }\n if (mutation.type === 'patch') {\n return patch(document, mutation)\n }\n // @ts-expect-error all cases should be covered\n throw new Error(`Invalid mutation type: ${mutation.type}`)\n}\n\nfunction create<Doc extends SanityDocumentBase>(\n document: Doc | undefined,\n mutation: CreateMutation<Doc>,\n): MutationResult<Doc> {\n if (document) {\n return {status: 'error', message: 'Document already exist'}\n }\n const result = assignId(mutation.document, nanoid)\n return {status: 'created', id: result._id, after: result}\n}\n\nfunction createIfNotExists<Doc extends SanityDocumentBase>(\n document: Doc | undefined,\n mutation: CreateIfNotExistsMutation<Doc>,\n): MutationResult<Doc> {\n if (!hasId(mutation.document)) {\n return {\n status: 'error',\n message: 'Cannot createIfNotExists on document without _id',\n }\n }\n return document\n ? {status: 'noop'}\n : {status: 'created', id: mutation.document._id, after: mutation.document}\n}\n\nfunction createOrReplace<Doc extends SanityDocumentBase>(\n document: Doc | undefined,\n mutation: CreateOrReplaceMutation<Doc>,\n): MutationResult<Doc> {\n if (!hasId(mutation.document)) {\n return {\n status: 'error',\n message: 'Cannot createIfNotExists on document without _id',\n }\n }\n\n return document\n ? {\n status: 'updated',\n id: mutation.document._id,\n before: document,\n after: mutation.document,\n }\n : {status: 'created', id: mutation.document._id, after: mutation.document}\n}\n\nfunction del<Doc extends SanityDocumentBase>(\n document: Doc | undefined,\n mutation: DeleteMutation,\n): MutationResult<Doc> {\n if (!document) {\n return {status: 'noop'}\n }\n if (mutation.id !== document._id) {\n return {status: 'error', message: 'Delete mutation targeted wrong document'}\n }\n return {\n status: 'deleted',\n id: mutation.id,\n before: document,\n after: undefined,\n }\n}\n\nfunction patch<Doc extends SanityDocumentBase>(\n document: Doc | undefined,\n mutation: PatchMutation,\n): MutationResult<Doc> {\n if (!document) {\n return {\n status: 'error',\n message: 'Cannot apply patch on nonexistent document',\n }\n }\n const next = applyPatchMutation(mutation, document)\n return document === next\n ? {status: 'noop'}\n : {status: 'updated', id: mutation.id, before: document, after: next}\n}\n","import {applyPatch, type RawPatch} from 'mendoza'\n\nimport {type SanityDocumentBase} from '../../mutations/types'\n\nfunction omitRev(document: SanityDocumentBase | undefined) {\n if (document === undefined) {\n return undefined\n }\n const {_rev, ...doc} = document\n return doc\n}\n\nexport function applyMendozaPatch(\n document: SanityDocumentBase | undefined,\n patch: RawPatch,\n patchBaseRev?: string,\n): SanityDocumentBase | undefined {\n if (patchBaseRev !== document?._rev) {\n throw new Error(\n 'Invalid document revision. The provided patch is calculated from a different revision than the current document',\n )\n }\n const next = applyPatch(omitRev(document), patch)\n return next === null ? undefined : next\n}\n\nexport function applyMutationEventEffects(\n document: SanityDocumentBase | undefined,\n event: {effects: {apply: RawPatch}; previousRev?: string; resultRev?: string},\n) {\n if (!event.effects) {\n throw new Error(\n 'Mutation event is missing effects. Is the listener set up with effectFormat=mendoza?',\n )\n }\n const next = applyMendozaPatch(\n document,\n event.effects.apply,\n event.previousRev,\n )\n // next will be undefined in case of deletion\n return next ? {...next, _rev: event.resultRev} : undefined\n}\n","export function hasProperty<T, P extends keyof T>(\n value: T,\n property: P,\n): value is T & Required<Pick<T, P>> {\n const val = value[property]\n return typeof val !== 'undefined' && val !== null\n}\n","import {filter, type Observable} from 'rxjs'\nimport {scan} from 'rxjs/operators'\n\nimport {decodeAll} from '../../encoders/sanity'\nimport {type SanityDocumentBase} from '../../mutations/types'\nimport {applyAll} from '../documentMap/applyDocumentMutation'\nimport {applyMutationEventEffects} from '../documentMap/applyMendoza'\nimport {\n type ListenerEvent,\n type ListenerMutationEvent,\n type ListenerReconnectEvent,\n type ListenerSyncEvent,\n} from '../types'\nimport {hasProperty} from '../utils/isEffectEvent'\n\nexport interface DocumentSyncUpdate<Doc extends SanityDocumentBase> {\n documentId: string\n snapshot: Doc | undefined\n event: ListenerSyncEvent<Doc>\n}\nexport interface DocumentMutationUpdate<Doc extends SanityDocumentBase> {\n documentId: string\n snapshot: Doc | undefined\n event: ListenerMutationEvent\n}\n\nexport interface DocumentReconnectUpdate<Doc extends SanityDocumentBase> {\n documentId: string\n snapshot: Doc | undefined\n event: ListenerReconnectEvent\n}\n\nexport type DocumentUpdate<Doc extends SanityDocumentBase> =\n | DocumentSyncUpdate<Doc>\n | DocumentMutationUpdate<Doc>\n | DocumentReconnectUpdate<any>\n\nexport type DocumentUpdateListener<Doc extends SanityDocumentBase> = (\n id: string,\n) => Observable<DocumentUpdate<Doc>>\n\n/**\n * Creates a function that can be used to listen for document updates\n * Emits the latest snapshot of the document along with the latest event\n * @param options\n */\nexport function createDocumentUpdateListener(options: {\n listenDocumentEvents: (documentId: string) => Observable<ListenerEvent>\n}) {\n const {listenDocumentEvents} = options\n\n return function listen<Doc extends SanityDocumentBase>(documentId: string) {\n return listenDocumentEvents(documentId).pipe(\n scan(\n (\n prev: DocumentUpdate<Doc> | undefined,\n event: ListenerEvent,\n ): DocumentUpdate<Doc> => {\n if (event.type === 'sync') {\n return {\n event,\n documentId,\n snapshot: event.document,\n } as DocumentUpdate<Doc>\n }\n if (event.type === 'mutation') {\n if (prev?.event === undefined) {\n throw new Error(\n 'Received a mutation event before sync event. Something is wrong',\n )\n }\n if (hasProperty(event, 'effects')) {\n return {\n event,\n documentId,\n snapshot: applyMutationEventEffects(\n prev.snapshot,\n event,\n ) as Doc,\n }\n }\n if (hasProperty(event, 'mutations')) {\n return {\n event,\n documentId,\n snapshot: applyAll(\n prev.snapshot,\n decodeAll(event.mutations),\n ) as Doc,\n }\n }\n throw new Error(\n 'No effects found on listener event. The listener must be set up to use effectFormat=mendoza.',\n )\n }\n return {documentId, snapshot: prev?.snapshot, event}\n },\n undefined,\n ),\n // ignore seed value\n filter(update => update !== undefined),\n )\n }\n}\n","import {type SanityClient} from '@sanity/client'\nimport {sortedIndex} from 'lodash'\nimport {type Observable, of} from 'rxjs'\nimport {filter, map, mergeMap, scan} from 'rxjs/operators'\n\nimport {type ListenerEndpointEvent, type QueryParams} from '../types'\n\nexport type DocumentIdSetState = {\n status: 'connecting' | 'reconnecting' | 'connected'\n event: DocumentIdSetEvent | InitialEvent\n snapshot: string[]\n}\n\nexport type InitialEvent = {type: 'connect'}\n\nexport type InsertMethod = 'sorted' | 'prepend' | 'append'\n\nexport type DocumentIdSetEvent =\n | {type: 'sync'; documentIds: string[]}\n | {type: 'reconnect'}\n | {\n type: 'op'\n op: 'add' | 'remove'\n documentId: string\n }\n\nconst INITIAL_STATE: DocumentIdSetState = {\n status: 'connecting',\n event: {type: 'connect'},\n snapshot: [],\n}\n\nexport type FetchDocumentIdsFn = (\n query: string,\n params?: QueryParams,\n options?: {tag?: string},\n) => Observable<string[]>\n\nexport type IdSetListenFn = (\n query: string,\n params?: QueryParams,\n options?: {\n visibility: 'transaction'\n events: ['welcome', 'mutation', 'reconnect']\n includeResult: false\n includeMutations: false\n tag?: string\n },\n) => Observable<ListenerEndpointEvent>\n\nexport function createIdSetListener(\n listen: IdSetListenFn,\n fetch: FetchDocumentIdsFn,\n) {\n return function listenIdSet(\n queryFilter: string,\n params: QueryParams,\n options: {tag?: string} = {},\n ) {\n const {tag} = options\n\n const query = `*[${queryFilter}]._id`\n function fetchFilter() {\n return fetch(query, params, {\n tag: tag ? tag + '.fetch' : undefined,\n }).pipe(\n map((result): string[] => {\n if (!Array.isArray(result)) {\n throw new Error(\n `Expected query to return array of documents, but got ${typeof result}`,\n )\n }\n return result as string[]\n }),\n )\n }\n return listen(query, params, {\n visibility: 'transaction',\n events: ['welcome', 'mutation', 'reconnect'],\n includeResult: false,\n includeMutations: false,\n tag: tag ? tag + '.listen' : undefined,\n }).pipe(\n mergeMap(event => {\n return event.type === 'welcome'\n ? fetchFilter().pipe(map(result => ({type: 'sync' as const, result})))\n : of(event)\n }),\n map((event): DocumentIdSetEvent | undefined => {\n if (event.type === 'mutation') {\n if (event.transition === 'update') {\n // ignore updates, as we're only interested in documents appearing and disappearing from the set\n return undefined\n }\n if (event.transition === 'appear') {\n return {\n type: 'op',\n op: 'add',\n documentId: event.documentId,\n }\n }\n if (event.transition === 'disappear') {\n return {\n type: 'op',\n op: 'remove',\n documentId: event.documentId,\n }\n }\n return undefined\n }\n if (event.type === 'sync') {\n return {type: 'sync', documentIds: event.result}\n }\n if (event.type === 'reconnect') {\n return {type: 'reconnect' as const}\n }\n return undefined\n }),\n // ignore undefined\n filter(ev => !!ev),\n )\n }\n}\nexport function createIdSetListenerFromClient(client: SanityClient) {}\n\n/** Converts a stream of id set listener events into a state containing the list of document ids */\nexport function toState(options: {insert?: InsertMethod} = {}) {\n const {insert: insertOption = 'sorted'} = options\n return (input$: Observable<DocumentIdSetEvent>) =>\n input$.pipe(\n scan((state: DocumentIdSetState, event): DocumentIdSetState => {\n if (event.type === 'reconnect') {\n return {\n ...state,\n event,\n status: 'reconnecting',\n }\n }\n if (event.type === 'sync') {\n return {\n ...state,\n event,\n status: 'connected',\n }\n }\n if (event.type === 'op') {\n if (event.op === 'add') {\n return {\n event,\n status: 'connected',\n snapshot: insert(state.snapshot, event.documentId, insertOption),\n }\n }\n if (event.op === 'remove') {\n return {\n event,\n status: 'connected',\n snapshot: state.snapshot.filter(id => id !== event.documentId),\n }\n }\n throw new Error(`Unexpected operation: ${event.op}`)\n }\n return state\n }, INITIAL_STATE),\n )\n}\n\nfunction insert<T>(array: T[], element: T, strategy: InsertMethod): T[] {\n let index: number\n if (strategy === 'prepend') {\n index = 0\n } else if (strategy === 'append') {\n index = array.length\n } else {\n index = sortedIndex(array, element) as number\n }\n\n return array.toSpliced(index, 0, element)\n}\n","import {\n finalize,\n merge,\n type MonoTypeOperatorFunction,\n Observable,\n share,\n type ShareConfig,\n tap,\n} from 'rxjs'\n\nexport type ShareReplayLatestConfig<T> = ShareConfig<T> & {\n predicate: (value: T) => boolean\n}\n\n/**\n * A variant of share that takes a predicate function to determine which value to replay to new subscribers\n * @param predicate - Predicate function to determine which value to replay\n */\nexport function shareReplayLatest<T>(\n predicate: (value: T) => boolean,\n): MonoTypeOperatorFunction<T>\n\n/**\n * A variant of share that takes a predicate function to determine which value to replay to new subscribers\n * @param config - ShareConfig with additional predicate function\n */\nexport function shareReplayLatest<T>(\n config: ShareReplayLatestConfig<T>,\n): MonoTypeOperatorFunction<T>\n\n/**\n * A variant of share that takes a predicate function to determine which value to replay to new subscribers\n * @param configOrPredicate - Predicate function to determine which value to replay\n * @param config - Optional ShareConfig\n */\nexport function shareReplayLatest<T>(\n configOrPredicate:\n | ShareReplayLatestConfig<T>\n | ShareReplayLatestConfig<T>['predicate'],\n config?: ShareConfig<T>,\n) {\n return _shareReplayLatest(\n typeof configOrPredicate === 'function'\n ? {predicate: configOrPredicate, ...config}\n : configOrPredicate,\n )\n}\nfunction _shareReplayLatest<T>(\n config: ShareReplayLatestConfig<T>,\n): MonoTypeOperatorFunction<T> {\n return (source: Observable<T>) => {\n let latest: T | undefined\n let emitted = false\n\n const {predicate, ...shareConfig} = config\n\n const wrapped = source.pipe(\n tap(value => {\n if (config.predicate(value)) {\n emitted = true\n latest = value\n }\n }),\n finalize(() => {\n emitted = false\n latest = undefined\n }),\n share(shareConfig),\n )\n const emitLatest = new Observable<T>(subscriber => {\n if (emitted) {\n subscriber.next(latest)\n }\n subscriber.complete()\n })\n return merge(wrapped, emitLatest)\n }\n}\n","import {map, type Observable} from 'rxjs'\n\nimport {\n type ListenerEndpointEvent,\n type ListenerMutationEvent,\n} from '../../types'\nimport {ChannelError, DisconnectError} from '../errors'\n/**\n * Takes a stream of /listen events and turn them into errors in case of disconnect or channelError\n */\nexport function withListenErrors() {\n return (input$: Observable<ListenerEndpointEvent>) =>\n input$.pipe(\n map(event => {\n if (event.type === 'mutation') {\n return event as ListenerMutationEvent\n }\n if (event.type === 'disconnect') {\n throw new DisconnectError(`DisconnectError: ${event.reason}`)\n }\n if (event.type === 'channelError') {\n throw new ChannelError(`ChannelError: ${event.message}`)\n }\n // pass on welcome and reconnect events\n // note: reconnect is special and should not be subject to error path + retry because that will reinstantiate the eventsource instance\n return event\n }),\n )\n}\n","import {\n type ReconnectEvent,\n type SanityClient,\n type WelcomeEvent,\n} from '@sanity/client'\nimport {type Observable, timer} from 'rxjs'\n\nimport {\n type ListenerEndpointEvent,\n type ListenerMutationEvent,\n type QueryParams,\n} from '../types'\nimport {shareReplayLatest} from './utils/shareReplayLatest'\nimport {withListenErrors} from './utils/withListenErrors'\n\nexport interface ListenerOptions {\n /**\n * Provide a custom filter to the listener. By default, this listener will include all events\n * Note: make sure the filter includes events from documents you will subscribe to.\n */\n filter?: string\n /**\n * Whether to include system documents or not\n * This will be ignored if a custom filter is provided\n */\n includeSystemDocuments?: boolean\n /**\n * How long after the last subscriber is unsubscribed to keep the connection open\n */\n shutdownDelay?: number\n /**\n * Include mutations in listener events\n */\n includeMutations?: boolean\n\n /**\n * Request tag\n */\n tag?: string\n}\n\nexport type SharedListenerListenFn = (\n query: string,\n queryParams: QueryParams,\n options: RequestOptions,\n) => Observable<ListenerEndpointEvent>\n\n/**\n * These are fixed, and it's up to the implementation of the listen function to turn them into request parameters\n */\nexport interface RequestOptions {\n events: ['welcome', 'mutation', 'reconnect']\n includeResult: false\n includePreviousRevision: false\n visibility: 'transaction'\n effectFormat: 'mendoza'\n includeMutations?: boolean\n tag?: string\n}\n\n/**\n * Creates a (low level) shared listener that will emit 'welcome' for all new subscribers immediately, and thereafter emit every listener event, including welcome, mutation, and reconnects\n * Requires a Sanity client instance\n */\nexport function createSharedListenerFromClient(\n client: SanityClient,\n options?: ListenerOptions,\n): Observable<WelcomeEvent | ListenerMutationEvent | ReconnectEvent> {\n const listener = (\n query: string,\n queryParams: QueryParams,\n request: RequestOptions,\n ) => {\n return client.listen(\n query,\n queryParams,\n request,\n ) as Observable<ListenerEndpointEvent>\n }\n\n return createSharedListener(listener, options)\n}\n\n/**\n * Creates a (low level) shared listener that will emit 'welcome' for all new subscribers immediately, and thereafter emit every listener event, including welcome, mutation, and reconnects\n * Useful for cases where you need control of how the listen request is set up\n */\nexport function createSharedListener(\n listen: SharedListenerListenFn,\n options: ListenerOptions = {},\n): Observable<WelcomeEvent | ListenerMutationEvent | ReconnectEvent> {\n const {filter, tag, shutdownDelay, includeSystemDocuments, includeMutations} =\n options\n\n const query = filter\n ? `*[${filter}]`\n : includeSystemDocuments\n ? '*[!(_id in path(\"_.**\"))]'\n : '*'\n\n return listen(\n query,\n {},\n {\n events: ['welcome', 'mutation', 'reconnect'],\n includeResult: false,\n includePreviousRevision: false,\n visibility: 'transaction',\n effectFormat: 'mendoza',\n ...(includeMutations ? {} : {includeMutations: false}),\n tag,\n },\n ).pipe(\n shareReplayLatest({\n // note: resetOnError and resetOnComplete are both default true\n resetOnError: true,\n resetOnComplete: true,\n predicate: event =>\n event.type === 'welcome' || event.type === 'reconnect',\n resetOnRefCountZero:\n typeof shutdownDelay === 'number' ? () => timer(shutdownDelay) : true,\n }),\n withListenErrors(),\n )\n}\n","type MutationLike =\n | {type: 'patch'; id: string}\n | {type: 'create'; document: {_id: string}}\n | {type: 'delete'; id: string}\n | {type: 'createIfNotExists'; document: {_id: string}}\n | {type: 'createOrReplace'; document: {_id: string}}\n\nexport function getMutationDocumentId(mutation: MutationLike): string {\n if (mutation.type === 'patch') {\n return mutation.id\n }\n if (mutation.type === 'create') {\n return mutation.document._id\n }\n if (mutation.type === 'delete') {\n return mutation.id\n }\n if (mutation.type === 'createIfNotExists') {\n return mutation.document._id\n }\n if (mutation.type === 'createOrReplace') {\n return mutation.document._id\n }\n throw new Error('Invalid mutation type')\n}\n","import {type Mutation, type SanityDocumentBase} from '../../mutations/types'\nimport {type DocumentMap} from '../types'\nimport {getMutationDocumentId} from '../utils/getMutationDocumentId'\nimport {applyDocumentMutation} from './applyDocumentMutation'\n\nexport interface UpdateResult<T extends SanityDocumentBase> {\n id: string\n status: 'created' | 'updated' | 'deleted'\n before?: T\n after?: T\n mutations: Mutation[]\n}\n\n/**\n * Takes a list of mutations and applies them to documents in a documentMap\n */\nexport function applyMutations<T extends SanityDocumentBase>(\n mutations: Mutation[],\n documentMap: DocumentMap<T>,\n /**\n * note: should never be set client side – only for test purposes\n */\n transactionId?: never,\n): UpdateResult<T>[] {\n const updatedDocs: Record<\n string,\n {\n before: T | undefined\n after: T | undefined\n mutations: Mutation[]\n }\n > = Object.create(null)\n\n for (const mutation of mutations) {\n const documentId = getMutationDocumentId(mutation)\n if (!documentId) {\n throw new Error('Unable to get document id from mutation')\n }\n\n const before = updatedDocs[documentId]?.after || documentMap.get(documentId)\n const res = applyDocumentMutation(before, mutation)\n if (res.status === 'error') {\n throw new Error(res.message)\n }\n\n let entry = updatedDocs[documentId]\n if (!entry) {\n entry = {before, after: before, mutations: []}\n updatedDocs[documentId] = entry\n }\n\n // Note: transactionId should never be set client side. Only for test purposes\n // if a transaction id is passed, set it as a new _rev\n const after = transactionId\n ? {...(res.status === 'noop' ? before : res.after), _rev: transactionId}\n : res.status === 'noop'\n ? before\n : res.after\n\n documentMap.set(documentId, after)\n entry.after = after\n entry.mutations.push(mutation)\n }\n\n return Object.entries(updatedDocs).map(\n ([id, {before, after, mutations: muts}]) => {\n return {\n id,\n status: after ? (before ? 'updated' : 'created') : 'deleted',\n mutations: muts,\n before,\n after,\n }\n },\n )\n}\n","import {type SanityDocumentBase} from '../../mutations/types'\n\n/**\n * Minimalistic dataset implementation that only supports what's strictly necessary\n */\nexport function createDocumentMap() {\n const documents = new Map<string, SanityDocumentBase | undefined>()\n return {\n set: (id: string, doc: SanityDocumentBase | undefined) =>\n void documents.set(id, doc),\n get: (id: string) => documents.get(id),\n delete: (id: string) => documents.delete(id),\n }\n}\n","import {uuid} from '@sanity/uuid'\n\nexport function createTransactionId() {\n return uuid()\n}\n","import {partition} from 'lodash'\nimport {concat, filter, merge, NEVER, type Observable, of, Subject} from 'rxjs'\nimport {map} from 'rxjs/operators'\n\nimport {encodeAll} from '../../encoders/sanity'\nimport {type Transaction} from '../../mutations/types'\nimport {applyMutations} from '../documentMap/applyMutations'\nimport {createDocumentMap} from '../documentMap/createDocumentMap'\nimport {type DocEndpointResponse} from '../listeners/createDocumentLoader'\nimport {\n type ListenerEndpointEvent,\n type ListenerWelcomeEvent,\n type SubmitResult,\n} from '../types'\nimport {createTransactionId} from '../utils/createTransactionId'\n\nfunction createWelcomeEvent(): ListenerWelcomeEvent {\n return {\n type: 'welcome',\n listenerName: 'mock' + Math.random().toString(32).substring(2),\n }\n}\n\n/**\n * This is the interface that a mock backend instance needs to implement\n */\nexport interface MockBackendAPI {\n listen(query: string): Observable<ListenerEndpointEvent>\n getDocuments(ids: string[]): Observable<DocEndpointResponse>\n submit(transaction