diff --git a/packages/sanity/src/core/releases/store/reducer.ts b/packages/sanity/src/core/releases/store/reducer.ts index 6684cae628c8..f3fe6171ac69 100644 --- a/packages/sanity/src/core/releases/store/reducer.ts +++ b/packages/sanity/src/core/releases/store/reducer.ts @@ -45,6 +45,8 @@ export interface ReleasesReducerState { /** * An array of release ids ordered chronologically to represent the state of documents at the * given point in time. + * + * TODO: Are we using this for anything? */ releaseStack: string[] } diff --git a/packages/sanity/src/core/releases/tool/detail/activity/__fixtures__/testHelpers.ts b/packages/sanity/src/core/releases/tool/detail/activity/__fixtures__/release-events.ts similarity index 100% rename from packages/sanity/src/core/releases/tool/detail/activity/__fixtures__/testHelpers.ts rename to packages/sanity/src/core/releases/tool/detail/activity/__fixtures__/release-events.ts diff --git a/packages/sanity/src/core/releases/tool/detail/activity/buildReleaseEditEvents.ts b/packages/sanity/src/core/releases/tool/detail/activity/buildReleaseEditEvents.ts index 58bfc0d2f0d8..5eb7bc158a34 100644 --- a/packages/sanity/src/core/releases/tool/detail/activity/buildReleaseEditEvents.ts +++ b/packages/sanity/src/core/releases/tool/detail/activity/buildReleaseEditEvents.ts @@ -9,9 +9,8 @@ export function buildReleaseEditEvents( transactions: TransactionLogEventWithEffects[], release: ReleaseDocument, ): (EditReleaseEvent | CreateReleaseEvent)[] { - // Be sure we have all the events by checking the first transaction id and the release._rev - - if (release._rev !== transactions[0].id) { + // Confirm we have all the events by checking the first transaction id and the release._rev, the should match. + if (release._rev !== transactions[0]?.id) { console.error('Some transactions are missing, cannot calculate the edit events') return [] } diff --git a/packages/sanity/src/core/releases/tool/detail/activity/getReleaseActivityEvents.test.ts b/packages/sanity/src/core/releases/tool/detail/activity/getReleaseActivityEvents.test.ts index a2860737f78c..a1f72a2dd2ec 100644 --- a/packages/sanity/src/core/releases/tool/detail/activity/getReleaseActivityEvents.test.ts +++ b/packages/sanity/src/core/releases/tool/detail/activity/getReleaseActivityEvents.test.ts @@ -3,7 +3,11 @@ import {TestScheduler} from 'rxjs/testing' import {type SanityClient} from 'sanity' import {beforeEach, describe, expect, it, vi} from 'vitest' -import {getReleaseActivityEvents, RELEASE_ACTIVITY_INITIAL_VALUE} from './getReleaseActivityEvents' +import { + addIdToEvent, + getReleaseActivityEvents, + RELEASE_ACTIVITY_INITIAL_VALUE, +} from './getReleaseActivityEvents' import {type ReleaseEvent} from './types' const mockObservableRequest = vi.fn() @@ -34,18 +38,6 @@ const addSecondDocumentEvent: Omit = { author: 'user-2', } -const addIdToEvent = (event: Omit) => ({ - ...event, - id: `${event.timestamp}-${event.type}`, -}) - -const initialValue = { - events: [], - loading: true, - error: null, - nextCursor: '', -} - describe('getReleaseActivityEvents', () => { let testScheduler: TestScheduler @@ -73,7 +65,7 @@ describe('getReleaseActivityEvents', () => { const {events$} = getReleaseActivityEvents({client: mockClient, releaseId: 'release123'}) testScheduler.run(({expectObservable}) => { expectObservable(events$).toBe('(ab)', { - a: initialValue, + a: RELEASE_ACTIVITY_INITIAL_VALUE, b: { events: [addIdToEvent(addFirstDocumentEvent), addIdToEvent(creationEvent)], nextCursor: 'cursor1', @@ -113,7 +105,7 @@ describe('getReleaseActivityEvents', () => { actions.subscribe((action) => action()) expectObservable(events$).toBe('(ab)-(cd)', { - a: initialValue, + a: RELEASE_ACTIVITY_INITIAL_VALUE, b: { events: [addIdToEvent(addFirstDocumentEvent), addIdToEvent(creationEvent)], nextCursor: 'cursor1', @@ -170,7 +162,7 @@ describe('getReleaseActivityEvents', () => { actions.subscribe((action) => action()) expectObservable(events$).toBe('(ab)-(cd)', { - a: initialValue, + a: RELEASE_ACTIVITY_INITIAL_VALUE, b: { loading: false, nextCursor: 'cursor2', diff --git a/packages/sanity/src/core/releases/tool/detail/activity/getReleaseActivityEvents.ts b/packages/sanity/src/core/releases/tool/detail/activity/getReleaseActivityEvents.ts index 8d4a8e7441f8..8b6ea7f7a28e 100644 --- a/packages/sanity/src/core/releases/tool/detail/activity/getReleaseActivityEvents.ts +++ b/packages/sanity/src/core/releases/tool/detail/activity/getReleaseActivityEvents.ts @@ -32,7 +32,7 @@ function removeDupes(prev: ReleaseEvent[], next: ReleaseEvent[]): ReleaseEvent[] return Array.from(noDupes.values()) } -function addId(event: Omit): ReleaseEvent { +export function addIdToEvent(event: Omit): ReleaseEvent { return {...event, id: `${event.timestamp}-${event.type}`} as ReleaseEvent } @@ -68,7 +68,7 @@ export function getReleaseActivityEvents({client, releaseId}: InitialFetchEvents .pipe( map((response) => { return { - events: response.events.map(addId), + events: response.events.map(addIdToEvent), nextCursor: response.nextCursor, loading: false, error: null, diff --git a/packages/sanity/src/core/releases/tool/detail/activity/getReleaseEditEvents.test.ts b/packages/sanity/src/core/releases/tool/detail/activity/getReleaseEditEvents.test.ts new file mode 100644 index 000000000000..e92242b57306 --- /dev/null +++ b/packages/sanity/src/core/releases/tool/detail/activity/getReleaseEditEvents.test.ts @@ -0,0 +1,278 @@ +import {type TransactionLogEventWithEffects} from '@sanity/types' +import {TestScheduler} from 'rxjs/testing' +import {type ReleaseDocument, type SanityClient} from 'sanity' +import {afterEach, beforeEach, describe, expect, it, type Mock, vi} from 'vitest' + +import {getTransactionsLogs} from '../../../../store/translog/getTransactionLogs' +import { + EDITS_EVENTS_INITIAL_VALUE, + type getReleaseEditEvents as getReleaseEditEventsFunction, +} from './getReleaseEditEvents' + +const mockClient = { + config: vi.fn().mockReturnValue({dataset: 'testDataset'}), +} as unknown as SanityClient + +vi.mock('../../../../store/translog/getTransactionLogs', () => { + return { + getTransactionsLogs: vi.fn(), + } +}) +const MOCKED_RELEASE = { + userId: '', + _createdAt: '2024-12-05T16:34:59Z', + _rev: 'mocked-rev', + name: 'rWBfpXZVj', + state: 'active', + _updatedAt: '2024-12-05T17:09:28Z', + metadata: { + releaseType: 'undecided', + description: '', + title: 'winter drop', + }, + publishAt: null, + _id: '_.releases.rWBfpXZVj', + _type: 'system.release', + finalDocumentStates: null, +} as unknown as ReleaseDocument + +const MOCKED_TRANSACTION_LOGS: TransactionLogEventWithEffects[] = [ + { + id: 'mocked-rev', + timestamp: '2024-12-05T17:09:28.325641Z', + author: 'p8xDvUMxC', + documentIDs: ['_.releases.rWBfpXZVj'], + effects: { + '_.releases.rWBfpXZVj': { + apply: [ + 11, + 3, + 23, + 0, + 12, + 22, + '7:09:28', + 23, + 19, + 20, + 15, + 10, + 5, + 19, + 1, + 17, + 'undecided', + 'releaseType', + 15, + ], + revert: [ + 11, + 3, + 23, + 0, + 12, + 22, + '6:35:11', + 23, + 19, + 20, + 15, + 10, + 5, + 17, + '2024-12-20T16:35:00.000Z', + 'intendedPublishAt', + 17, + 'scheduled', + 'releaseType', + 15, + ], + }, + }, + }, +] + +const MOCKED_EVENT = { + type: 'releaseEditEvent', + author: 'p8xDvUMxC', + change: {releaseType: 'undecided', intendedPublishDate: undefined}, + id: 'mocked-rev', + timestamp: '2024-12-05T17:09:28.325641Z', + releaseName: 'rWBfpXZVj', +} +const mockGetTransactionsLogs = getTransactionsLogs as Mock + +const MOCKED_RELEASES_STATE = { + state: 'loaded' as const, + releaseStack: [], + releases: new Map([[MOCKED_RELEASE._id, MOCKED_RELEASE]]), +} + +describe('getReleaseEditEvents()', () => { + let testScheduler: TestScheduler + let getReleaseEditEvents: typeof getReleaseEditEventsFunction + beforeEach(async () => { + // We need to reset the module and reassign it because it has an internal cache that we need to evict + vi.resetModules() + const testModule = await import('./getReleaseEditEvents') + getReleaseEditEvents = testModule.getReleaseEditEvents + + testScheduler = new TestScheduler((actual, expected) => { + expect(actual).toEqual(expected) + }) + }) + afterEach(() => { + vi.resetAllMocks() + }) + it('should initialize with the default value if releaseId is not provided', () => { + testScheduler.run(({expectObservable, hot}) => { + const releasesState$ = hot('a', {a: MOCKED_RELEASES_STATE}) + const {editEvents$} = getReleaseEditEvents({ + client: mockClient, + releaseId: undefined, + releasesState$: releasesState$, + }) + expectObservable(editEvents$).toBe('(a|)', {a: EDITS_EVENTS_INITIAL_VALUE}) + }) + }) + it('should not get the events if release is undefined', () => { + testScheduler.run(({expectObservable, hot}) => { + const releasesState$ = hot('a', {a: MOCKED_RELEASES_STATE}) + + const {editEvents$} = getReleaseEditEvents({ + client: mockClient, + releaseId: 'not-existing-release', + releasesState$, + }) + + expectObservable(editEvents$).toBe('(a)', {a: EDITS_EVENTS_INITIAL_VALUE}) + }) + }) + it('should get and build the release edit events', () => { + testScheduler.run(({expectObservable, cold, hot}) => { + const releasesState$ = hot('a', {a: MOCKED_RELEASES_STATE}) + + const {editEvents$} = getReleaseEditEvents({ + client: mockClient, + releaseId: MOCKED_RELEASE._id, + releasesState$, + }) + const mockResponse$ = cold('-a|', {a: MOCKED_TRANSACTION_LOGS}) + mockGetTransactionsLogs.mockReturnValueOnce(mockResponse$) + expectObservable(editEvents$).toBe('ab', { + a: {editEvents: [], loading: true}, + b: { + editEvents: [MOCKED_EVENT], + loading: false, + }, + }) + }) + expect(mockGetTransactionsLogs).toHaveBeenCalledWith(mockClient, MOCKED_RELEASE._id, { + effectFormat: 'mendoza', + fromTransaction: undefined, + limit: 100, + reverse: true, + tag: 'sanity.studio.release.history', + toTransaction: MOCKED_RELEASE._rev, + }) + }) + it('should not refetch the edit events if rev has not changed', () => { + testScheduler.run(({expectObservable, cold, hot}) => { + // Simulate the release states changing over time, but the _rev is the same + // 'a' at frame 0: initial state with _rev=rev1 + // 'b' at frame 5: updated state with _rev=rev2 + const releasesState$ = hot('a---b', { + a: MOCKED_RELEASES_STATE, + b: MOCKED_RELEASES_STATE, + }) + const {editEvents$} = getReleaseEditEvents({ + client: mockClient, + releaseId: MOCKED_RELEASE._id, + releasesState$: releasesState$, + }) + const mockResponse$ = cold('-a|', {a: MOCKED_TRANSACTION_LOGS}) + mockGetTransactionsLogs.mockReturnValueOnce(mockResponse$) + // Even though the state changes, the editEvents$ should not emit again + expectObservable(editEvents$).toBe('ab', { + a: {editEvents: [], loading: true}, + b: { + editEvents: [MOCKED_EVENT], + loading: false, + }, + }) + }) + expect(mockGetTransactionsLogs).toHaveBeenCalledWith(mockClient, MOCKED_RELEASE._id, { + effectFormat: 'mendoza', + fromTransaction: undefined, + limit: 100, + reverse: true, + tag: 'sanity.studio.release.history', + toTransaction: MOCKED_RELEASE._rev, + }) + }) + it('should refetch the edit events if release._rev changes', () => { + testScheduler.run(({expectObservable, cold, hot}) => { + // Define the initial and updated release state + const updatedReleaseState = { + ...MOCKED_RELEASES_STATE, + releases: new Map([[MOCKED_RELEASE._id, {...MOCKED_RELEASE, _rev: 'changed-rev'}]]), + } + // Simulate the release states changing over time + // 'a' at frame 0: initial state with _rev=rev1 + // 'b' at frame 5: updated state with _rev=rev2 + const releasesState$ = hot('a---b', { + a: MOCKED_RELEASES_STATE, + b: updatedReleaseState, + }) + + const {editEvents$} = getReleaseEditEvents({ + client: mockClient, + releaseId: MOCKED_RELEASE._id, + releasesState$: releasesState$, + }) + + const mockResponse$ = cold('-a|', {a: MOCKED_TRANSACTION_LOGS}) + const newTransaction = { + id: 'changed-rev', + timestamp: '2024-12-05T17:10:28.325641Z', + author: 'p8xDvUMxC', + documentIDs: ['_.releases.rWBfpXZVj'], + effects: {}, + } + // It only returns the new transactions, the rest are from the cache, so they will be persisted. + const mockResponse2$ = cold('-a|', {a: [newTransaction]}) + + mockGetTransactionsLogs.mockReturnValueOnce(mockResponse$).mockReturnValueOnce(mockResponse2$) + + expectObservable(editEvents$).toBe('ab---c', { + a: {editEvents: [], loading: true}, + b: { + editEvents: [MOCKED_EVENT], + loading: false, + }, + c: { + editEvents: [MOCKED_EVENT], + loading: false, + }, + }) + }) + expect(mockGetTransactionsLogs).toHaveBeenCalledWith(mockClient, MOCKED_RELEASE._id, { + effectFormat: 'mendoza', + fromTransaction: undefined, + limit: 100, + reverse: true, + tag: 'sanity.studio.release.history', + toTransaction: MOCKED_RELEASE._rev, + }) + expect(mockGetTransactionsLogs).toHaveBeenCalledWith(mockClient, MOCKED_RELEASE._id, { + effectFormat: 'mendoza', + // Uses the previous release._rev as the fromTransaction + fromTransaction: MOCKED_RELEASE._rev, + limit: 100, + reverse: true, + tag: 'sanity.studio.release.history', + // Uses the new release._rev as the toTransaction + toTransaction: 'changed-rev', + }) + }) +}) diff --git a/packages/sanity/src/core/releases/tool/detail/activity/getReleaseEditEvents.ts b/packages/sanity/src/core/releases/tool/detail/activity/getReleaseEditEvents.ts index b506b874dc92..167ab2461b75 100644 --- a/packages/sanity/src/core/releases/tool/detail/activity/getReleaseEditEvents.ts +++ b/packages/sanity/src/core/releases/tool/detail/activity/getReleaseEditEvents.ts @@ -13,7 +13,7 @@ import { tap, } from 'rxjs' -import {getJsonStream} from '../../../../store/_legacy/history/history/getJsonStream' +import {getTransactionsLogs} from '../../../../store/translog/getTransactionLogs' import {type ReleasesReducerState} from '../../../store/reducer' import {buildReleaseEditEvents} from './buildReleaseEditEvents' import {type CreateReleaseEvent, type EditReleaseEvent} from './types' @@ -23,80 +23,51 @@ const TRANSLOG_ENTRY_LIMIT = 100 const documentTransactionsCache: Record = Object.create(null) -export async function getReleaseTransactions({ +function removeDupes( + newTransactions: TransactionLogEventWithEffects[], + oldTransactions: TransactionLogEventWithEffects[], +) { + const seen = new Set() + return newTransactions.concat(oldTransactions).filter((transaction) => { + if (seen.has(transaction.id)) { + return false + } + seen.add(transaction.id) + return true + }) +} + +function getReleaseTransactions({ documentId, client, toTransaction, }: { documentId: string client: SanityClient - toTransaction?: string -}): Promise { + toTransaction: string +}): Observable { const cacheKey = `${documentId}` - let fromTransaction: string | undefined const cachedTransactions = documentTransactionsCache[cacheKey] || [] - if (cachedTransactions.length > 0) { - if (cachedTransactions[0].id === toTransaction) { - return cachedTransactions - } - // Assign the last cached transaction as the from, we can use that as the entry point in the translog and not fetch unnecessary elements. - fromTransaction = cachedTransactions[0].id - } - const clientConfig = client.config() - const dataset = clientConfig.dataset - - const queryParams = new URLSearchParams({ - tag: 'sanity.studio.release.history', - effectFormat: 'mendoza', - excludeContent: 'true', - includeIdentifiedDocumentsOnly: 'true', - limit: TRANSLOG_ENTRY_LIMIT.toString(), - reverse: 'true', - }) - if (fromTransaction) { - queryParams.append('fromTransaction', fromTransaction) - } - if (toTransaction) { - queryParams.append('toTransaction', toTransaction) + if (cachedTransactions.length > 0 && cachedTransactions[0].id === toTransaction) { + return of(cachedTransactions) } - const transactionsUrl = client.getUrl( - `/data/history/${dataset}/transactions/${documentId}?${queryParams.toString()}`, + return from( + getTransactionsLogs(client, documentId, { + tag: 'sanity.studio.release.history', + effectFormat: 'mendoza', + limit: TRANSLOG_ENTRY_LIMIT, + reverse: true, + fromTransaction: cachedTransactions[0]?.id, + toTransaction, + }), + ).pipe( + // TODO: Add a load more + map((transactions) => removeDupes(transactions, cachedTransactions)), + tap((transactions) => { + documentTransactionsCache[cacheKey] = transactions + }), ) - const transactions: TransactionLogEventWithEffects[] = [] - - const stream = await getJsonStream(transactionsUrl, clientConfig.token) - const reader = stream.getReader() - let count = 0 - for (;;) { - // eslint-disable-next-line no-await-in-loop - const result = await reader.read() - if (result.done) break - - if ('error' in result.value) { - throw new Error(result.value.error.description || result.value.error.type) - } - transactions.push(result.value) - count++ - } - if (count === TRANSLOG_ENTRY_LIMIT) { - // // We have received the max values, we need to fetch the next batch. - // // TODO: Validate this loading more - // const nextTransactions = await getReleaseTransactions({ - // documentId, - // client, - // toTransaction: transactions[transactions.length - 1].id, - // }) - // return transactions.concat(nextTransactions) - // } - } - - documentTransactionsCache[cacheKey] = transactions.concat( - cachedTransactions.filter( - (cached) => !transactions.find((transaction) => transaction.id === cached.id), - ), - ) - return documentTransactionsCache[cacheKey] } interface getReleaseActivityEventsOpts { @@ -123,34 +94,32 @@ export function getReleaseEditEvents({ } { if (!releaseId) { return { - editEvents$: of({editEvents: [], loading: false}), + editEvents$: of({editEvents: [], loading: true}), } } let lastRevProcessed = '' return { editEvents$: releasesState$.pipe( map((releasesState) => releasesState.releases.get(releaseId)), - // Remove the undefined releases + // Don't emit if the release is not found filter(Boolean), - // ReleaseState$ is changing a lot, we only need to update this if the `_rev` changes + // ReleaseState$ is changing a lot and it could change because of other releases changing, we only need to update this if the `_rev` changes filter((release) => lastRevProcessed !== release._rev), tap((release) => { lastRevProcessed = release._rev }), switchMap((release) => { - return from( - getReleaseTransactions({ - client, - documentId: releaseId, - toTransaction: release?._rev, - }), - ).pipe( + return getReleaseTransactions({ + client, + documentId: releaseId, + toTransaction: release._rev, + }).pipe( map((transactions) => { return {editEvents: buildReleaseEditEvents(transactions, release), loading: false} }), - startWith(EDITS_EVENTS_INITIAL_VALUE), ) }), + startWith(EDITS_EVENTS_INITIAL_VALUE), scan((acc, current) => { // Accumulate edit events from previous state const editEvents = current.loading diff --git a/packages/sanity/src/core/store/translog/getTransactionLogs.ts b/packages/sanity/src/core/store/translog/getTransactionLogs.ts new file mode 100644 index 000000000000..9655eddae2d3 --- /dev/null +++ b/packages/sanity/src/core/store/translog/getTransactionLogs.ts @@ -0,0 +1,108 @@ +import {type SanityClient} from '@sanity/client' +import {type TransactionLogEventWithEffects} from '@sanity/types' + +import {getJsonStream} from '../_legacy/history/history/getJsonStream' + +/** + * Fetches transaction logs for the specified document IDs from the translog + * It adds the default query parameters to the request and also reads the stream of transactions. + * @internal + */ +export async function getTransactionsLogs( + client: SanityClient, + /** + * 1 or more document IDs to fetch transaction logs */ + documentIds: string | string[], + /** + * {@link https://www.sanity.io/docs/history-api#45ac5eece4ca} + */ + params: { + /** + * The tag that will be use when fetching the transactions. + * (Default: sanity.studio.transactions-log) + */ + tag?: `sanity.studio.${string}` + /** + * Exclude the document contents from the responses. (You are required to set excludeContent as true for now.) + * (Default: true) + */ + excludeContent?: true + /** + * Limit the number of returned transactions. (Default: 50) + */ + limit?: number + + /** + * Only include the documents that are part of the document ids list + * (Default: true) + */ + includeIdentifiedDocumentsOnly?: boolean + + /** + * How the effects are formatted in the response. + * "mendoza": Super efficient format for expressing differences between JSON documents. {@link https://www.sanity.io/blog/mendoza} + */ + effectFormat?: 'mendoza' | undefined + /** + * Return transactions in reverse order. + */ + reverse?: boolean + /** + * Time from which the transactions are fetched. + */ + fromTime?: string + /** + * Time until the transactions are fetched. + */ + toTime?: string + /** + * Transaction ID (Or, Revision ID) from which the transactions are fetched. + */ + fromTransaction?: string + /** + * Transaction ID (Or, Revision ID) until the transactions are fetched. + */ + toTransaction?: string + /** + * Comma separated list of authors to filter the transactions by. + */ + authors?: string + }, +): Promise { + const clientConfig = client.config() + const dataset = clientConfig.dataset + const queryParams = new URLSearchParams({ + // Default values + tag: 'sanity.studio.transactions-log', + excludeContent: 'true', + limit: '50', + includeIdentifiedDocumentsOnly: 'true', + }) + Object.entries(params).forEach(([key, value]) => { + if (value !== undefined) { + queryParams.set(key, value.toString()) + } + }) + + const transactionsUrl = client.getUrl( + `/data/history/${dataset}/transactions/${ + Array.isArray(documentIds) ? documentIds.join(',') : documentIds + }?${queryParams.toString()}`, + ) + + const stream = await getJsonStream(transactionsUrl, clientConfig.token) + const transactions: TransactionLogEventWithEffects[] = [] + + const reader = stream.getReader() + for (;;) { + // eslint-disable-next-line no-await-in-loop + const result = await reader.read() + if (result.done) break + + if ('error' in result.value) { + throw new Error(result.value.error.description || result.value.error.type) + } + transactions.push(result.value) + } + return transactions +}