UNPKG

@sanity/sdk

Version:
1,092 lines (906 loc) 38.3 kB
import { type BaseActionOptions, type FilteredResponseQueryOptions, type ListenEvent, type MultipleActionResult, type MutationEvent, type RawQueryResponse, type ResponseQueryOptions, type SanityClient, type SingleActionResult, type UnfilteredResponseQueryOptions, type WelcomeEvent, } from '@sanity/client' import {diffValue} from '@sanity/diff-patch' import {type Mutation, type SanityDocument} from '@sanity/types' import {evaluate, parse} from 'groq-js' import {delay, first, firstValueFrom, from, Observable, of, ReplaySubject, Subject} from 'rxjs' import {afterEach, beforeEach, expect, it, vi} from 'vitest' import {getClientState} from '../client/clientStore' import {createDocumentHandle} from '../config/handles' import {createSanityInstance, type SanityInstance} from '../store/createSanityInstance' import {type StateSource} from '../store/createStateSourceAction' import {getDraftId, getPublishedId} from '../utils/ids' import { createDocument, deleteDocument, discardDocument, editDocument, publishDocument, unpublishDocument, } from './actions' import {applyDocumentActions} from './applyDocumentActions' import { getDocumentState, getDocumentSyncStatus, getPermissionsState, resolveDocument, resolvePermissions, subscribeDocumentEvents, } from './documentStore' import {type ActionErrorEvent, type TransactionRevertedEvent} from './events' import {type DatasetAcl} from './permissions' import {type DocumentSet, processMutations} from './processMutations' import {type HttpAction} from './reducers' import {createFetchDocument, createSharedListener} from './sharedListener' // Define a single generic TestDocument type interface TestDocument extends SanityDocument { _type: 'article' title?: string } // Scope the TestDocument type to the project/datasets used in tests type AllTestSchemaTypes = TestDocument // Augment the 'groq' module declare module 'groq' { interface SanitySchemas { default: AllTestSchemaTypes } } let instance: SanityInstance let instance1: SanityInstance let instance2: SanityInstance beforeEach(() => { instance = createSanityInstance({projectId: 'p', dataset: 'd'}) // test uses two instances that share the same in-memory dataset, but separate // store instances. in real scenarios, this would be separate machines but with // the same project + dataset instance1 = createSanityInstance({projectId: 'p', dataset: 'd1'}) instance2 = createSanityInstance({projectId: 'p', dataset: 'd2'}) }) afterEach(() => { instance?.dispose() instance1?.dispose() instance2?.dispose() }) it('creates, edits, and publishes a document', async () => { const doc = createDocumentHandle({documentId: 'doc-single', documentType: 'article'}) const documentState = getDocumentState(instance, doc) // Initially the document is undefined expect(documentState.getCurrent()).toBeUndefined() const unsubscribe = documentState.subscribe() // Create a new document const {appeared} = await applyDocumentActions(instance, {actions: [createDocument(doc)]}) expect(appeared).toContain(getDraftId(doc.documentId)) let currentDoc = documentState.getCurrent() expect(currentDoc?._id).toEqual(getDraftId(doc.documentId)) // Edit the document – add a title await applyDocumentActions(instance, { actions: [editDocument(doc, {set: {title: 'My First Article'}})], }) currentDoc = documentState.getCurrent() expect(currentDoc?.title).toEqual('My First Article') // Publish the document; the resulting transactionId is used as the new _rev const {transactionId, submitted} = await applyDocumentActions(instance, { actions: [publishDocument(doc)], }) await submitted() currentDoc = documentState.getCurrent() expect(currentDoc).toMatchObject({_id: doc.documentId, _rev: transactionId}) unsubscribe() }) it('creates a document with initial values', async () => { const doc = createDocumentHandle({documentId: 'doc-with-initial', documentType: 'article'}) const documentState = getDocumentState(instance, doc) expect(documentState.getCurrent()).toBeUndefined() const unsubscribe = documentState.subscribe() // Create a new document with initial field values const {appeared} = await applyDocumentActions(instance, { actions: [ createDocument(doc, { title: 'Article with Initial Values', author: 'Jane Doe', count: 42, }), ], }) expect(appeared).toContain(getDraftId(doc.documentId)) const currentDoc = documentState.getCurrent() expect(currentDoc?._id).toEqual(getDraftId(doc.documentId)) expect(currentDoc?.title).toEqual('Article with Initial Values') expect(currentDoc?.['author']).toEqual('Jane Doe') expect(currentDoc?.['count']).toEqual(42) unsubscribe() }) it('edits existing documents', async () => { const doc = createDocumentHandle({documentId: 'existing-doc', documentType: 'article'}) const state = getDocumentState(instance, doc) // not subscribed yet so the value is undefined expect(state.getCurrent()).toBeUndefined() const unsubscribe = state.subscribe() // wait for it to populate await firstValueFrom(state.observable.pipe(first((i) => !!i))) expect(state.getCurrent()).toMatchObject({ _id: getDraftId(doc.documentId), title: 'existing doc', }) await applyDocumentActions(instance, { actions: [editDocument(doc, {set: {title: 'updated title'}})], }) expect(state.getCurrent()).toMatchObject({ _id: getDraftId(doc.documentId), title: 'updated title', }) unsubscribe() }) it('sets optimistic changes synchronously', async () => { const doc = createDocumentHandle({documentId: 'optimistic', documentType: 'article'}) const state1 = getDocumentState(instance1, doc) const state2 = getDocumentState(instance2, doc) const unsubscribe1 = state1.subscribe() const unsubscribe2 = state2.subscribe() // wait until the value is primed in the store await resolveDocument(instance1, doc) // then the actions are synchronous expect(state1.getCurrent()).toBeNull() applyDocumentActions(instance1, {actions: [createDocument(doc)]}) expect(state1.getCurrent()).toMatchObject({_id: getDraftId(doc.documentId)}) const actionResult1Promise = applyDocumentActions(instance1, { actions: [editDocument(doc, {set: {title: 'initial title'}})], }) expect(state1.getCurrent()?.title).toBe('initial title') // notice how state2 doesn't have the value yet because it's a different // instance and the value needs to come over the mock network expect(state2.getCurrent()?.title).toBe(undefined) // after we await the action result, it still shouldn't be submitted yet // so `state2` should still be undefined const actionResult1 = await actionResult1Promise expect(state2.getCurrent()?.title).toBe(undefined) // if we await until the action is fully submitted, then it should show up // in the other instance await actionResult1.submitted() expect(state2.getCurrent()?.title).toBe('initial title') // synchronous for state 2 const actionResult2Promise = applyDocumentActions(instance2, { actions: [editDocument(doc, {set: {title: 'updated title'}})], }) expect(state2.getCurrent()?.title).toBe('updated title') // async for state 1 expect(state1.getCurrent()?.title).toBe('initial title') const actionResult2 = await actionResult2Promise await actionResult2.submitted() expect(state1.getCurrent()?.title).toBe('updated title') unsubscribe1() unsubscribe2() }) it('propagates changes between two instances', async () => { const doc = createDocumentHandle({documentId: 'doc-collab', documentType: 'article'}) const state1 = getDocumentState(instance1, doc) const state2 = getDocumentState(instance2, doc) const state1Unsubscribe = state1.subscribe() const state2Unsubscribe = state2.subscribe() // Create the document from instance1. await applyDocumentActions(instance1, {actions: [createDocument(doc)]}).then((r) => r.submitted()) const doc1 = state1.getCurrent() const doc2 = state2.getCurrent() expect(doc1?._id).toEqual(getDraftId(doc.documentId)) expect(doc2?._id).toEqual(getDraftId(doc.documentId)) // Now, edit the document from instance2. await applyDocumentActions(instance2, { actions: [editDocument(doc, {set: {title: 'Hello world!'}})], }).then((r) => r.submitted()) const updated1 = state1.getCurrent() const updated2 = state2.getCurrent() expect(updated1?.title).toEqual('Hello world!') expect(updated2?.title).toEqual('Hello world!') state1Unsubscribe() state2Unsubscribe() }) it('handles concurrent edits and resolves conflicts', async () => { const doc = createDocumentHandle({documentId: 'doc-concurrent', documentType: 'article'}) const state1 = getDocumentState(instance1, doc) const state2 = getDocumentState(instance2, doc) const state1Unsubscribe = state1.subscribe() const state2Unsubscribe = state2.subscribe() const oneOffInstance = createSanityInstance({projectId: 'p', dataset: 'd'}) // Create the initial document from a one-off instance. await applyDocumentActions(oneOffInstance, { actions: [ createDocument(doc), editDocument(doc, {set: {title: 'The quick brown fox jumps over the lazy dog'}}), ], }).then((res) => res.submitted()) // Both instances now issue an edit simultaneously. const p1 = applyDocumentActions(instance1, { actions: [editDocument(doc, {set: {title: 'The quick brown fox jumps over the lazy cat'}})], }).then((r) => r.submitted()) const p2 = applyDocumentActions(instance2, { actions: [ editDocument(doc, {set: {title: 'The quick brown elephant jumps over the lazy dog'}}), ], }).then((r) => r.submitted()) // Wait for both actions to complete (or reject). await Promise.allSettled([p1, p2]) const finalDoc1 = state1.getCurrent() const finalDoc2 = state2.getCurrent() expect(finalDoc1?.title).toEqual(finalDoc2?.title) expect(finalDoc1?.title).toBe('The quick brown elephant jumps over the lazy cat') state1Unsubscribe() state2Unsubscribe() oneOffInstance.dispose() }) it('unpublishes and discards a document', async () => { const doc = createDocumentHandle({documentId: 'doc-pub-unpub', documentType: 'article'}) const documentState = getDocumentState(instance, doc) const unsubscribe = documentState.subscribe() // Create and publish the document. await applyDocumentActions(instance, {actions: [createDocument(doc)]}) const afterPublish = await applyDocumentActions(instance, {actions: [publishDocument(doc)]}) const publishedDoc = documentState.getCurrent() expect(publishedDoc).toMatchObject({ _id: getPublishedId(doc.documentId), _rev: afterPublish.transactionId, }) // Unpublish the document (which should delete the published version and create a draft). await applyDocumentActions(instance, {actions: [unpublishDocument(doc)]}) const afterUnpublish = documentState.getCurrent() // In our mock implementation the _id remains the same but the published copy is removed. expect(afterUnpublish?._id).toEqual(getDraftId(doc.documentId)) // Discard the draft (which deletes the draft version). await applyDocumentActions(instance, {actions: [discardDocument(doc)]}) const afterDiscard = documentState.getCurrent() expect(afterDiscard).toBeNull() unsubscribe() }) it('deletes a document', async () => { const doc = createDocumentHandle({documentId: 'doc-delete', documentType: 'article'}) const documentState = getDocumentState(instance, doc) const unsubscribe = documentState.subscribe() await applyDocumentActions(instance, {actions: [createDocument(doc), publishDocument(doc)]}) const docValue = documentState.getCurrent() expect(docValue).toBeDefined() // Delete the document. await applyDocumentActions(instance, {actions: [deleteDocument(doc)]}) const afterDelete = documentState.getCurrent() expect(afterDelete).toBeNull() unsubscribe() }) it('cleans up document state when there are no subscribers', async () => { const doc = createDocumentHandle({documentId: 'doc-cleanup', documentType: 'article'}) const documentState = getDocumentState(instance, doc) // Subscribe to the document state. const unsubscribe = documentState.subscribe() // Create a document. await applyDocumentActions(instance, {actions: [createDocument(doc)]}) expect(documentState.getCurrent()).toBeDefined() // Unsubscribe from the document. unsubscribe() // Wait longer than DOCUMENT_STATE_CLEAR_DELAY (our mock sets it to 25ms) await new Promise((resolve) => setTimeout(resolve, 30)) // When a new subscriber is created, if the state was cleared it should return undefined. const newDocumentState = getDocumentState(instance, doc) expect(newDocumentState.getCurrent()).toBeUndefined() }) it('fetches documents if there are no active subscriptions for the actions applied', async () => { const doc = createDocumentHandle({documentId: 'existing-doc', documentType: 'article'}) const {getCurrent} = getDocumentState(instance, doc) expect(getCurrent()).toBeUndefined() expect(getDocumentSyncStatus(instance, doc).getCurrent()).toBeUndefined() // there are no active subscriptions so applying this action will create one // for this action. this subscription will be removed when the outgoing // transaction for this action has been accepted by the server const setNewTitle = applyDocumentActions(instance, { actions: [editDocument(doc, {set: {title: 'new title'}})], }) expect(getCurrent()?.title).toBeUndefined() expect(getDocumentSyncStatus(instance, doc).getCurrent()).toBe(false) await setNewTitle expect(getCurrent()?.title).toBe('new title') // there is an active subscriber now so the edits are synchronous applyDocumentActions(instance, {actions: [editDocument(doc, {set: {title: 'updated title'}})]}) expect(getCurrent()?.title).toBe('updated title') applyDocumentActions(instance, {actions: [editDocument(doc, {set: {title: 'updated title!'}})]}) expect(getCurrent()?.title).toBe('updated title!') expect(getDocumentSyncStatus(instance, doc).getCurrent()).toBe(false) // await submitted in order to test that there is no subscriptions const result = await applyDocumentActions(instance, { actions: [editDocument(doc, {set: {title: 'updated title'}})], }) await result.submitted() // test that there isn't any document state expect(getDocumentSyncStatus(instance, doc).getCurrent()).toBeUndefined() const setNewNewTitle = applyDocumentActions(instance, { actions: [editDocument(doc, {set: {title: 'new new title'}})], }) // now we'll have to await again expect(getCurrent()?.title).toBe(undefined) await setNewNewTitle expect(getCurrent()?.title).toBe('new new title') }) it('batches edit transaction into one outgoing transaction', async () => { const doc = createDocumentHandle({documentId: crypto.randomUUID(), documentType: 'article'}) const unsubscribe = getDocumentState(instance, doc).subscribe() // this creates its own transaction applyDocumentActions(instance, {actions: [createDocument(doc)]}) // these get batched into one applyDocumentActions(instance, {actions: [editDocument(doc, {set: {title: 'name!'}})]}) applyDocumentActions(instance, {actions: [editDocument(doc, {set: {title: 'name!!'}})]}) applyDocumentActions(instance, {actions: [editDocument(doc, {set: {title: 'name!!!'}})]}) const res = await applyDocumentActions(instance, { actions: [editDocument(doc, {set: {title: 'name!!!!'}})], }) await res.submitted() expect(client.action).toHaveBeenCalledTimes(2) const [, [_actions]] = vi.mocked(client.action).mock.calls const actions = Array.isArray(_actions) ? _actions : [_actions] expect(actions.length > 0).toBe(true) expect(actions.every(({actionType}) => actionType === 'sanity.action.document.edit')).toBe(true) unsubscribe() }) it('provides the consistency status via `getDocumentSyncStatus`', async () => { const doc = createDocumentHandle({documentId: crypto.randomUUID(), documentType: 'article'}) const syncStatus = getDocumentSyncStatus(instance, doc) expect(syncStatus.getCurrent()).toBeUndefined() const unsubscribe = syncStatus.subscribe() expect(syncStatus.getCurrent()).toBe(true) const applied = applyDocumentActions(instance, {actions: [createDocument(doc)]}) expect(syncStatus.getCurrent()).toBe(false) const createResult = await applied expect(syncStatus.getCurrent()).toBe(false) await createResult.submitted() expect(syncStatus.getCurrent()).toBe(true) applyDocumentActions(instance, {actions: [editDocument(doc, {set: {title: 'initial name'}})]}) expect(syncStatus.getCurrent()).toBe(false) applyDocumentActions(instance, {actions: [editDocument(doc, {set: {title: 'updated name'}})]}) const publishResult = applyDocumentActions(instance, {actions: [publishDocument(doc)]}) expect(syncStatus.getCurrent()).toBe(false) await publishResult.then((res) => res.submitted()) expect(syncStatus.getCurrent()).toBe(true) unsubscribe() }) it('reverts failed outgoing transaction locally', async () => { const clientActionMockImplementation = vi.mocked(client.action).getMockImplementation()! vi.mocked(client.action).mockImplementation(async (...args) => { const [, {transactionId} = {}] = args if (transactionId === 'force-revert') throw new Error('example error') return await clientActionMockImplementation(...args) }) const revertedEventPromise = new Promise<TransactionRevertedEvent>((resolve) => { const unsubscribe = subscribeDocumentEvents(instance, (e) => { if (e.type === 'reverted') { resolve(e) unsubscribe() } }) }) const doc = createDocumentHandle({documentId: crypto.randomUUID(), documentType: 'article'}) const {getCurrent, subscribe} = getDocumentState(instance, doc) const unsubscribe = subscribe() await applyDocumentActions(instance, {actions: [createDocument(doc)]}) applyDocumentActions(instance, {actions: [editDocument(doc, {set: {title: 'the'}})]}) applyDocumentActions(instance, {actions: [editDocument(doc, {set: {title: 'the quick'}})]}) // this edit action is simulated to fail from the backend and will be reverted const revertedActionResult = applyDocumentActions(instance, { actions: [editDocument(doc, {set: {title: 'the quick brown'}})], transactionId: 'force-revert', disableBatching: true, }) applyDocumentActions(instance, { actions: [editDocument(doc, {set: {title: 'the quick brown fox'}})], }) await applyDocumentActions(instance, { actions: [editDocument(doc, {set: {title: 'the quick brown fox jumps'}})], }).then((e) => e.submitted()) await expect(revertedEventPromise).resolves.toMatchObject({ type: 'reverted', message: 'example error', outgoing: {transactionId: 'force-revert'}, }) // test that the submitted handler also rejects await expect(revertedActionResult.then((e) => e.submitted())).rejects.toThrowError( /example error/, ) // notice how `brown ` is gone expect(getCurrent()?.title).toBe('the quick fox jumps') // check that we can still edit after recovering from the error applyDocumentActions(instance, { actions: [editDocument(doc, {set: {title: 'TEST the quick fox jumps'}})], }) expect(getCurrent()?.title).toBe('TEST the quick fox jumps') unsubscribe() vi.mocked(client.action).mockImplementation(clientActionMockImplementation) }) it('removes a queued transaction if it fails to apply', async () => { const actionErrorEventPromise = new Promise<ActionErrorEvent>((resolve) => { const unsubscribe = subscribeDocumentEvents(instance, (e) => { if (e.type === 'error') { resolve(e) unsubscribe() } }) }) const doc = createDocumentHandle({documentId: crypto.randomUUID(), documentType: 'article'}) const state = getDocumentState(instance, doc) const unsubscribe = state.subscribe() await expect( applyDocumentActions(instance, {actions: [editDocument(doc, {set: {title: "can't set"}})]}), ).rejects.toThrowError(/Cannot edit document/) await expect(actionErrorEventPromise).resolves.toMatchObject({ documentId: doc.documentId, type: 'error', message: expect.stringContaining('Cannot edit document'), }) // editing should still work after though (no crashing) await applyDocumentActions(instance, {actions: [createDocument(doc)]}) applyDocumentActions(instance, {actions: [editDocument(doc, {set: {title: 'can set!'}})]}) expect(state.getCurrent()?.title).toBe('can set!') unsubscribe() }) it('returns allowed true when no permission errors occur', async () => { // Simulate a dataset ACL that allows all permissions. const datasetAcl = [{filter: 'true', permissions: ['read', 'update', 'create', 'history']}] // Override the client mock to return our dataset ACL. client.observable.request = vi.fn().mockReturnValue(of(datasetAcl)) // Create a document and subscribe to it. const doc = createDocumentHandle({ documentId: 'doc-perm-allowed', documentType: 'article', }) const state = getDocumentState(instance, doc) const unsubscribe = state.subscribe() await applyDocumentActions(instance, {actions: [createDocument(doc)]}).then((r) => r.submitted()) // Use an action that includes a patch (so that update permission check is bypassed). const permissionsState = getPermissionsState(instance, { actions: [ { ...doc, type: 'document.edit', patches: [{set: {title: 'New Title'}}], }, ], }) // Wait briefly to allow permissions calculation. await new Promise((resolve) => setTimeout(resolve, 10)) expect(permissionsState.getCurrent()).toEqual({allowed: true}) unsubscribe() }) it("should reject applying the action if a precondition isn't met", async () => { const doc = createDocumentHandle({documentId: 'does-not-exist', documentType: 'article'}) await expect(applyDocumentActions(instance, {actions: [deleteDocument(doc)]})).rejects.toThrow( 'The document you are trying to delete does not exist.', ) }) it("should reject applying the action if a permission isn't met", async () => { const doc = createDocumentHandle({documentId: 'does-not-exist', documentType: 'article'}) const datasetAcl = [{filter: 'false', permissions: ['create']}] vi.mocked(client.request).mockResolvedValue(datasetAcl) await expect(applyDocumentActions(instance, {actions: [createDocument(doc)]})).rejects.toThrow( 'You do not have permission to create a draft for document "does-not-exist".', ) }) it('returns allowed false with reasons when permission errors occur', async () => { const datasetAcl = [{filter: 'false', permissions: ['create']}] vi.mocked(client.request).mockResolvedValue(datasetAcl) const doc = createDocumentHandle({documentId: 'doc-perm-denied', documentType: 'article'}) const result = await resolvePermissions(instance, {actions: [createDocument(doc)]}) const message = 'You do not have permission to create a draft for document "doc-perm-denied".' expect(result).toMatchObject({ allowed: false, message, reasons: [{message, documentId: 'doc-perm-denied', type: 'access'}], }) }) it('fetches dataset ACL and updates grants in the document store state', async () => { // Simulate a dataset ACL response. const datasetAcl = [ {filter: '_type=="book"', permissions: ['read', 'update', 'create']}, {filter: '_type=="author"', permissions: ['update']}, ] vi.mocked(client.request).mockResolvedValue(datasetAcl) const book = createDocumentHandle({documentId: crypto.randomUUID(), documentType: 'book'}) const author = createDocumentHandle({documentId: crypto.randomUUID(), documentType: 'author'}) expect(await resolvePermissions(instance, {actions: [createDocument(book)]})).toEqual({ allowed: true, }) expect(await resolvePermissions(instance, {actions: [createDocument(author)]})).toMatchObject({ allowed: false, message: expect.stringContaining('You do not have permission to create a draft for document'), }) }) it('returns a promise that resolves when a document has been loaded in the store (useful for suspense)', async () => { const doc = createDocumentHandle({documentId: crypto.randomUUID(), documentType: 'article'}) expect(await resolveDocument(instance, doc)).toBe(null) // use one-off instance to create the document in the mock backend const oneOffInstance = createSanityInstance({projectId: 'p', dataset: 'd'}) const result = await applyDocumentActions(oneOffInstance, { actions: [createDocument(doc), editDocument(doc, {set: {title: 'initial title'}})], }) await result.submitted() // wait till submitted to server before resolving await expect(resolveDocument(instance, doc)).resolves.toMatchObject({ _id: getDraftId(doc.documentId), _type: 'article', title: 'initial title', }) oneOffInstance.dispose() }) it('emits an event for each action after an outgoing transaction has been accepted', async () => { const handler = vi.fn() const unsubscribe = subscribeDocumentEvents(instance, handler) const documentId = crypto.randomUUID() const doc = createDocumentHandle({documentId, documentType: 'article'}) expect(handler).toHaveBeenCalledTimes(0) const tnx1 = await applyDocumentActions(instance, { actions: [ createDocument(doc), editDocument(doc, {set: {title: 'new name'}}), publishDocument(doc), ], }).then((e) => e.submitted()) expect(handler).toHaveBeenCalledTimes(4) const tnx2 = await applyDocumentActions(instance, { actions: [ unpublishDocument(doc), publishDocument(doc), editDocument(doc, {set: {title: 'updated name'}}), discardDocument(doc), ], }).then((e) => e.submitted()) expect(handler).toHaveBeenCalledTimes(9) expect(handler.mock.calls).toMatchObject([ [{documentId, type: 'created', outgoing: {transactionId: tnx1.transactionId}}], [{documentId, type: 'edited', outgoing: {transactionId: tnx1.transactionId}}], [{documentId, type: 'published', outgoing: {transactionId: tnx1.transactionId}}], [{type: 'accepted', outgoing: {transactionId: tnx1.transactionId}}], [{documentId, type: 'unpublished', outgoing: {transactionId: tnx2.transactionId}}], [{documentId, type: 'published', outgoing: {transactionId: tnx2.transactionId}}], [{documentId, type: 'edited', outgoing: {transactionId: tnx2.transactionId}}], [{documentId, type: 'discarded', outgoing: {transactionId: tnx2.transactionId}}], [{type: 'accepted', outgoing: {transactionId: tnx2.transactionId}}], ]) await applyDocumentActions(instance, {actions: [deleteDocument(doc)]}) unsubscribe() }) vi.mock('../client/clientStore.ts', () => ({ getClientState: vi.fn().mockReturnValue({observable: new ReplaySubject(1)}), })) vi.mock('./sharedListener.ts', () => { const sharedListener = new Subject<ListenEvent<SanityDocument>>() const welcomeEvent: WelcomeEvent = {type: 'welcome', listenerName: 'mock-listener'} return { createFetchDocument: vi.fn(), createSharedListener: vi.fn().mockReturnValue({ dispose: vi.fn(), events: Object.assign( new Observable((observer) => { observer.next(welcomeEvent) return sharedListener.subscribe(observer) }), { next: sharedListener.next.bind(sharedListener), complete: sharedListener.complete.bind(sharedListener), error: sharedListener.error.bind(sharedListener), }, ), }), } }) vi.mock('./documentConstants.ts', async (importOriginal) => { const original = await importOriginal<typeof import('./documentConstants')>() return { ...original, INITIAL_OUTGOING_THROTTLE_TIME: 0, DOCUMENT_STATE_CLEAR_DELAY: 25, } }) let client: SanityClient beforeEach(() => { const client$ = (getClientState as () => StateSource<SanityClient>)() .observable as ReplaySubject<SanityClient> const sharedListener = ( createSharedListener as () => { dispose: () => void events: Subject<ListenEvent<SanityDocument>> } )() let documents: DocumentSet = { [getDraftId('existing-doc')]: { _id: getDraftId('existing-doc'), _createdAt: '2025-02-06T06:43:46.236Z', _updatedAt: '2025-02-06T06:43:46.236Z', _rev: 'initial-rev', _type: 'book', title: 'existing doc', }, } vi.mocked(createFetchDocument).mockReturnValue( vi.fn((id) => of(documents[id] ?? null).pipe( // add a bit of delay to simulate async doc resolution delay(0), ), ), ) const isNonNullable = <T>(t: T): t is NonNullable<T> => !!t const fetch = vi.fn( async ( query: string, params?: Record<string, unknown>, options: | ResponseQueryOptions | FilteredResponseQueryOptions | UnfilteredResponseQueryOptions = {}, ) => { const start = performance.now() const root = parse(query) const value = await evaluate(root, { dataset: Object.values(documents).filter(isNonNullable), params, }) const result = await value.get() let returnQuery if ('returnQuery' in options) { returnQuery = options.returnQuery } else { returnQuery = true } let filterResponse if ('filterResponse' in options) { filterResponse = options.filterResponse } else { filterResponse = false } if (!filterResponse) { const response: RawQueryResponse<unknown> = { ms: performance.now() - start, ...(returnQuery && {query}), result, query, } return response } return result }, ) as SanityClient['fetch'] const action = vi.fn( async ( input: HttpAction | HttpAction[], {transactionId = crypto.randomUUID(), dryRun}: BaseActionOptions, ): Promise<SingleActionResult | MultipleActionResult> => { const actions = Array.isArray(input) ? input : [input] let next: DocumentSet = {...documents} const timestamp = new Date().toISOString() for (const i of actions) { switch (i.actionType) { case 'sanity.action.document.delete': { const allIds: string[] = await fetch('sanity::versionOf($id)', {id: i.publishedId}) const draftIds = allIds.filter((id) => id !== i.publishedId) const draftsToDelete = new Set(i.includeDrafts) if (!draftIds.every((id) => draftsToDelete.has(id))) { throw new Error( `Found draft ids: \`${draftIds.join(',')}\` that were not included in action's \`includeDrafts\``, ) } next = processMutations({ documents: next, mutations: allIds.map((id) => ({delete: {id}})), transactionId, timestamp, }) continue } case 'sanity.action.document.edit': { const source = next[i.draftId] ?? next[i.publishedId] if (!source) { throw new Error( `Could not find a document to edit from \`draftId\` \`${i.draftId}\` or \`publishedId\` ${i.publishedId}`, ) } next = processMutations({ documents: next, mutations: [ {createIfNotExists: {...source, _id: i.draftId}}, {patch: {id: i.draftId, ...i.patch}}, ], transactionId, timestamp, }) continue } case 'sanity.action.document.publish': { const draft = next[i.draftId] if (!draft) { throw new Error( `Could not publish document because draft document with ID \`${i.draftId}\` was not found.`, ) } next = processMutations({ documents: next, mutations: [ {delete: {id: i.draftId}}, {createOrReplace: {...draft, _id: i.publishedId}}, ], transactionId, timestamp, }) continue } case 'sanity.action.document.unpublish': { const published = next[i.publishedId] if (!published) { throw new Error( `Could not unpublish because published document with ID \`${i.publishedId}\` was not found.`, ) } next = processMutations({ documents: next, mutations: [ {delete: {id: i.publishedId}}, {createIfNotExists: {...published, _id: i.draftId}}, ], transactionId, timestamp, }) continue } case 'sanity.action.document.version.create': { if (next[i.attributes._id]) { throw new Error( `Could not create version with ID \`${i.attributes._id}\` because it already exists.`, ) } next = processMutations({ documents: next, mutations: [{create: i.attributes}], transactionId, timestamp, }) continue } case 'sanity.action.document.version.discard': { if (!next[i.versionId]) { throw new Error( `Cannot discard document version with ID \`${i.versionId}\` because it was not found.`, ) } next = processMutations({ documents: next, mutations: [{delete: {id: i.versionId}}], transactionId, timestamp, }) continue } default: { throw new Error(`Unsupported action for mock backend: ${i.actionType}`) } } } if (!dryRun) { const existingIds = new Set( Object.entries(documents) .filter(([, value]) => !!value) .map(([key]) => key), ) const resultingIds = new Set( Object.entries(next) .filter(([, value]) => !!value) .map(([key]) => key), ) const allKeys = new Set([...existingIds, ...resultingIds]) const {appeared, disappeared, updated} = Array.from(allKeys).reduce<{ updated: string[] appeared: string[] disappeared: string[] }>( (acc, id) => { if (existingIds.has(id) && resultingIds.has(id)) { acc.updated.push(id) } else if (!existingIds.has(id) && resultingIds.has(id)) { acc.appeared.push(id) } else if (!resultingIds.has(id) && existingIds.has(id)) { acc.disappeared.push(id) } return acc }, {updated: [], appeared: [], disappeared: []}, ) const transactionTotalEvents = appeared.length + disappeared.length + updated.length let transactionCurrentEvent = 0 const mutationEvents: MutationEvent[] = [] for (const id of appeared) { transactionCurrentEvent++ // index seems to start at 1 const nextDoc = next[id]! mutationEvents.push({ type: 'mutation', documentId: id, eventId: `${transactionId}#${id}`, identity: 'example-user', mutations: [{create: nextDoc}], timestamp, transactionId, transactionCurrentEvent, transactionTotalEvents, transition: 'appear', visibility: 'query', resultRev: transactionId, }) } for (const id of updated) { transactionCurrentEvent++ const prevDoc = documents[id]! const nextDoc = next[id]! mutationEvents.push({ type: 'mutation', documentId: id, eventId: `${transactionId}#${id}`, identity: 'example-user', mutations: diffValue(prevDoc, nextDoc).map( (patch): Mutation => ({patch: {...patch, id}}), ), timestamp, transactionCurrentEvent, transactionTotalEvents, transactionId, transition: 'update', visibility: 'query', previousRev: prevDoc._rev, resultRev: transactionId, }) } for (const id of disappeared) { transactionCurrentEvent++ const prevDoc = documents[id]! mutationEvents.push({ type: 'mutation', documentId: id, eventId: `${transactionId}#${id}`, identity: 'example-user', mutations: [{delete: {id}}], timestamp, transactionId, transactionCurrentEvent, transactionTotalEvents, transition: 'disappear', visibility: 'query', previousRev: prevDoc._rev, }) } documents = next for (const mutationEvent of mutationEvents) { sharedListener.events.next(mutationEvent) } } // add a tick for realism await new Promise((resolve) => setTimeout(resolve, 0)) return {transactionId} }, ) as SanityClient['action'] const datasetAcl: DatasetAcl = [ {filter: 'true', permissions: ['create', 'history', 'read', 'update']}, ] const request = vi.fn(async () => { // add a bit of delay to simulate race conditions await new Promise((resolve) => setTimeout(resolve, 10)) return datasetAcl }) as SanityClient['request'] client = { action, fetch, request, observable: { action: (...args: Parameters<typeof action>) => from(action(...args)), fetch: (...args: Parameters<typeof fetch>) => from(fetch(...args)), request: (...args: Parameters<typeof request>) => from(request(...args)), }, } as SanityClient client$.next(client) })