diff --git a/packages/sanity/src/core/field/types.ts b/packages/sanity/src/core/field/types.ts index a281913b163..fbaf81465cd 100644 --- a/packages/sanity/src/core/field/types.ts +++ b/packages/sanity/src/core/field/types.ts @@ -25,40 +25,21 @@ import { } from '@sanity/types' import {type ComponentType} from 'react' +import {type DocumentGroupEvent} from '../store/events/types' import {type FieldValueError} from './validation' -/** - * History timeline / chunking - * - * - * @hidden - * @beta - */ -export type ChunkType = - | 'initial' - | 'create' - | 'editDraft' - | 'delete' - | 'publish' - | 'unpublish' - | 'discardDraft' - | 'editLive' - /** * @hidden * @beta */ export type Chunk = { index: number - id: string - type: ChunkType start: number end: number startTimestamp: string endTimestamp: string - authors: Set - draftState: 'present' | 'missing' | 'unknown' - publishedState: 'present' | 'missing' | 'unknown' + + event: DocumentGroupEvent } /** diff --git a/packages/sanity/src/core/store/_legacy/__workshop__/HistoryTimelineStory.tsx b/packages/sanity/src/core/store/_legacy/__workshop__/HistoryTimelineStory.tsx index 5e1340df69f..b807718cfa0 100644 --- a/packages/sanity/src/core/store/_legacy/__workshop__/HistoryTimelineStory.tsx +++ b/packages/sanity/src/core/store/_legacy/__workshop__/HistoryTimelineStory.tsx @@ -143,9 +143,9 @@ export default function HistoryTimelineStory() { selected={realRevChunk === chunk} > - {chunk.type} + {chunk.event.type} - {format(new Date(chunk.endTimestamp), 'MMM d, YYY @ HH:mm')} + {format(new Date(chunk.event.type), 'MMM d, YYY @ HH:mm')} @@ -169,7 +169,7 @@ export default function HistoryTimelineStory() { selected={sinceTime === chunk} > - {chunk.type} + {chunk.event.type} {format(new Date(chunk.endTimestamp), 'MMM d, YYY @ HH:mm')} diff --git a/packages/sanity/src/core/store/_legacy/history/history/Timeline.ts b/packages/sanity/src/core/store/_legacy/history/history/Timeline.ts index 59d7cfa44f4..527d78f4fe6 100644 --- a/packages/sanity/src/core/store/_legacy/history/history/Timeline.ts +++ b/packages/sanity/src/core/store/_legacy/history/history/Timeline.ts @@ -194,7 +194,11 @@ export class Timeline { const nextTransactionToChunk = this._chunks.length > 0 ? this._chunks.last.end : firstIdx for (let idx = nextTransactionToChunk; idx <= lastIdx; idx++) { const transaction = this._transactions.get(idx) - this._chunks.mergeAtEnd(chunkFromTransaction(transaction), mergeChunk) + const allTransactions = this._transactions.getAllItems + this._chunks.mergeAtEnd( + chunkFromTransaction(this.publishedId, transaction, allTransactions), + mergeChunk, + ) } // Add transactions at the beginning: @@ -204,7 +208,12 @@ export class Timeline { for (let idx = firstTransactionChunked - 1; idx >= firstIdx; idx--) { const transaction = this._transactions.get(idx) - this._chunks.mergeAtBeginning(chunkFromTransaction(transaction), mergeChunk) + const allTransactions = this._transactions.getAllItems + + this._chunks.mergeAtBeginning( + chunkFromTransaction(this.publishedId, transaction, allTransactions), + mergeChunk, + ) } } @@ -216,12 +225,14 @@ export class Timeline { private _createInitialChunk() { if (this.reachedEarliestEntry) { - if (this._chunks.first?.type === 'initial') return + if (this._chunks.first?.event.type === 'document.createVersion') return const firstTx = this._transactions.first if (!firstTx) return - const initialChunk = chunkFromTransaction(firstTx) - initialChunk.type = 'initial' + const allTransactions = this._transactions.getAllItems + + const initialChunk = chunkFromTransaction(this.publishedId, firstTx, allTransactions) + initialChunk.event.type = 'document.createVersion' initialChunk.id = '@initial' initialChunk.end = initialChunk.start this._chunks.addToBeginning(initialChunk) @@ -275,7 +286,7 @@ export class Timeline { chunkIdx-- ) { const currentChunk = this._chunks.get(chunkIdx) - if (currentChunk.type === 'publish' || currentChunk.type === 'initial') { + if (currentChunk.event.type === 'document.publishVersion' || currentChunk.id === '@initial') { return currentChunk } } diff --git a/packages/sanity/src/core/store/_legacy/history/history/TwoEndedArray.ts b/packages/sanity/src/core/store/_legacy/history/history/TwoEndedArray.ts index ed54c524c0e..a21e8cb04b3 100644 --- a/packages/sanity/src/core/store/_legacy/history/history/TwoEndedArray.ts +++ b/packages/sanity/src/core/store/_legacy/history/history/TwoEndedArray.ts @@ -90,6 +90,10 @@ export class TwoEndedArray { } } + get getAllItems(): T[] { + return [...this._negative.slice().reverse(), ...this._postive.slice()] + } + get lastIdx(): number { // Note: This also works correctly when _positive is empty (it returns -1) return this._postive.length - 1 diff --git a/packages/sanity/src/core/store/_legacy/history/history/chunker.ts b/packages/sanity/src/core/store/_legacy/history/history/chunker.ts index 4c3866cc478..578afc2049a 100644 --- a/packages/sanity/src/core/store/_legacy/history/history/chunker.ts +++ b/packages/sanity/src/core/store/_legacy/history/history/chunker.ts @@ -1,160 +1,68 @@ -/* eslint-disable no-nested-ternary */ -import {type MendozaEffectPair, type MendozaPatch} from '@sanity/types' - -import {type Chunk, type ChunkType} from '../../../../field' +import {type Chunk} from '../../../../field' +import {getEventFromTransaction} from '../../../events/getDocumentEvents' +import {type EditDocumentVersionEvent} from '../../../events/types' import {type Transaction} from './types' -function canMergeEdit(type: ChunkType) { - return type === 'create' || type === 'editDraft' -} - const CHUNK_WINDOW = 5 * 60 * 1000 // 5 minutes function isWithinMergeWindow(a: string, b: string) { return Date.parse(b) - Date.parse(a) < CHUNK_WINDOW } -export function mergeChunk(left: Chunk, right: Chunk): Chunk | [Chunk, Chunk] { - if (left.end !== right.start) throw new Error('chunks are not next to each other') - - // TODO: How to detect first squash/create - - const draftState = combineState(left.draftState, right.draftState) - const publishedState = combineState(left.publishedState, right.publishedState) - - if (left.type === 'delete' && right.type === 'editDraft') { - return [left, {...right, type: 'create', draftState, publishedState}] - } - - // Convert deletes into either discardDraft or unpublish depending on what's been deleted. - if (right.type === 'delete') { - if (draftState === 'missing' && publishedState === 'present') { - return [left, {...right, type: 'discardDraft', draftState, publishedState}] - } - - if (draftState === 'present' && publishedState === 'missing') { - return [left, {...right, type: 'unpublish', draftState, publishedState}] - } +const mergeEvents = ( + leftEvent: EditDocumentVersionEvent, + rightEvent: EditDocumentVersionEvent, +): EditDocumentVersionEvent => { + const mergedEvents = leftEvent.mergedEvents || [] + delete leftEvent.mergedEvents + return { + ...rightEvent, + mergedEvents: [...mergedEvents, leftEvent], } +} +/** + * @internal + * Decides whether to merge two chunks or not according to their type and timestamp + */ +export function mergeChunk(left: Chunk, right: Chunk): Chunk | [Chunk, Chunk] { + if (left.end !== right.start) throw new Error('chunks are not next to each other') if ( - canMergeEdit(left.type) && - right.type === 'editDraft' && + left.event.type === 'document.editVersion' && + right.event.type === 'document.editVersion' && isWithinMergeWindow(left.endTimestamp, right.startTimestamp) ) { - const authors = new Set() - for (const author of left.authors) authors.add(author) - for (const author of right.authors) authors.add(author) - return { index: 0, id: right.id, - type: left.type, start: left.start, end: right.end, + event: mergeEvents(left.event, right.event), startTimestamp: left.startTimestamp, endTimestamp: right.endTimestamp, - authors, - draftState, - publishedState, } } - return [left, {...right, draftState, publishedState}] -} - -type ChunkState = 'unedited' | 'deleted' | 'upsert' -function getChunkState(effect?: MendozaEffectPair): ChunkState { - const modified = Boolean(effect) - const deleted = effect && isDeletePatch(effect?.apply) - - if (deleted) { - return 'deleted' - } - - if (modified) { - return 'upsert' - } - - return 'unedited' + return [left, right] } -/* - * getChunkType tries to determine what effect the given transaction had on the document - * More information about the logic can be found here https://github.com/sanity-io/sanity/pull/2633#issuecomment-886461812 - * - * | | draft unedited | draft deleted | draft upsert | - * |--------------------|----------------|---------------|--------------| - * | published unedited | X | delete | editDraft | - * | published deleted | delete | delete | delete | - * | published upsert | liveEdit | publish | liveEdit | +/** + * @internal + * Creates a chunk for the timeline from a transaction. */ -function getChunkType(transaction: Transaction): ChunkType { - const draftState = getChunkState(transaction.draftEffect) - const publishedState = getChunkState(transaction.publishedEffect) - - if (publishedState === 'unedited') { - if (draftState === 'deleted') { - return 'delete' - } - - if (draftState === 'upsert') { - return 'editDraft' - } - } - - if (publishedState === 'deleted') { - return 'delete' - } - - if (publishedState === 'upsert') { - if (draftState === 'unedited') { - return 'editLive' - } - - if (draftState === 'deleted') { - return 'publish' - } - - if (draftState === 'upsert') { - return 'editLive' - } - } - - return 'editLive' -} - -export function chunkFromTransaction(transaction: Transaction): Chunk { - const modifiedDraft = Boolean(transaction.draftEffect) - const modifiedPublished = Boolean(transaction.publishedEffect) - - const draftDeleted = transaction.draftEffect && isDeletePatch(transaction.draftEffect.apply) - const publishedDeleted = - transaction.publishedEffect && isDeletePatch(transaction.publishedEffect.apply) - - const type = getChunkType(transaction) - +export function chunkFromTransaction( + publishedId: string, + transaction: Transaction, + transactions: Transaction[], +): Chunk { + const previousTransactions = transactions.filter((tx) => tx.index < transaction.index).reverse() return { index: 0, id: transaction.id, - type, start: transaction.index, end: transaction.index + 1, startTimestamp: transaction.timestamp, endTimestamp: transaction.timestamp, - authors: new Set([transaction.author]), - draftState: modifiedDraft ? (draftDeleted ? 'missing' : 'present') : 'unknown', - publishedState: modifiedPublished ? (publishedDeleted ? 'missing' : 'present') : 'unknown', + event: getEventFromTransaction(publishedId, transaction, previousTransactions), } } - -function combineState( - left: 'present' | 'missing' | 'unknown', - right: 'present' | 'missing' | 'unknown', -) { - return right === 'unknown' ? left : right -} - -export function isDeletePatch(patch: MendozaPatch): boolean { - return patch[0] === 0 && patch[1] === null -} diff --git a/packages/sanity/src/core/store/_legacy/history/history/index.ts b/packages/sanity/src/core/store/_legacy/history/history/index.ts index f296ba22477..5fb26001b99 100644 --- a/packages/sanity/src/core/store/_legacy/history/history/index.ts +++ b/packages/sanity/src/core/store/_legacy/history/history/index.ts @@ -1,3 +1,4 @@ +export * from './chunker' export * from './Timeline' export * from './TimelineController' export * from './types' diff --git a/packages/sanity/src/core/store/_legacy/history/useTimelineStore.ts b/packages/sanity/src/core/store/_legacy/history/useTimelineStore.ts index 403a755fbb7..c1ed83be268 100644 --- a/packages/sanity/src/core/store/_legacy/history/useTimelineStore.ts +++ b/packages/sanity/src/core/store/_legacy/history/useTimelineStore.ts @@ -202,8 +202,13 @@ export function useTimelineStore({ .pipe( map((innerController) => { const chunks = innerController.timeline.mapChunks((c) => c) - const lastNonDeletedChunk = chunks.filter( - (chunk) => !['delete', 'initial'].includes(chunk.type), + const lastNonDeletedChunk = chunks.find( + (chunk) => + ![ + 'document.deleteGroup', + 'document.deleteVersion', + 'document.createVersion', + ].includes(chunk.event.type), ) const hasMoreChunks = !innerController.timeline.reachedEarliestEntry @@ -220,7 +225,7 @@ export function useTimelineStore({ isLoading: false, isPristine: timelineReady ? chunks.length === 0 && hasMoreChunks === false : null, hasMoreChunks: !innerController.timeline.reachedEarliestEntry, - lastNonDeletedRevId: lastNonDeletedChunk?.[0]?.id, + lastNonDeletedRevId: lastNonDeletedChunk?.id || null, onOlderRevision: innerController.onOlderRevision(), realRevChunk: innerController.realRevChunk, revTime: innerController.revTime, @@ -229,7 +234,7 @@ export function useTimelineStore({ sinceTime: innerController.sinceTime, timelineDisplayed: innerController.displayed(), timelineReady, - } + } satisfies TimelineState }), // Only emit (and in turn, re-render) when values have changed distinctUntilChanged(deepEquals), diff --git a/packages/sanity/src/core/store/events/getDocumentEvents.test.ts b/packages/sanity/src/core/store/events/getDocumentEvents.test.ts new file mode 100644 index 00000000000..9664ecc0f24 --- /dev/null +++ b/packages/sanity/src/core/store/events/getDocumentEvents.test.ts @@ -0,0 +1,1991 @@ +import {type TransactionLogEventWithEffects} from '@sanity/types' +import {describe, expect, it} from 'vitest' + +import {getDocumentEvents} from './getDocumentEvents' +import { + type CreateDocumentVersionEvent, + type CreateLiveDocumentEvent, + type DeleteDocumentGroupEvent, + type DeleteDocumentVersionEvent, + type DocumentGroupEvent, + type EditDocumentVersionEvent, + type PublishDocumentVersionEvent, + type UpdateLiveDocumentEvent, +} from './types' + +describe('getDocumentEvents', () => { + describe('document.createVersion', () => { + it('creates a draft version', () => { + const transactions = [ + { + id: '3fb05c27-2beb-4228-95c4-48f33151dc80', + timestamp: '2024-09-30T07:49:41.413474Z', + author: 'p8xDvUMxC', + documentIDs: ['drafts.foo'], + effects: { + 'drafts.foo': { + apply: [ + 0, + { + _createdAt: '2024-09-30T07:49:40Z', + _id: 'drafts.foo', + _type: 'author', + _updatedAt: '2024-09-30T07:49:41Z', + name: 'bar', + }, + ], + revert: [0, null], + }, + }, + }, + ] + const expectedEvent: CreateDocumentVersionEvent = { + id: '3fb05c27-2beb-4228-95c4-48f33151dc80', + timestamp: '2024-09-30T07:49:41.413474Z', + type: 'document.createVersion', + documentId: 'foo', + versionId: 'drafts.foo', + versionRevisionId: '3fb05c27-2beb-4228-95c4-48f33151dc80', + author: 'p8xDvUMxC', + releaseId: undefined, + } + const events = getDocumentEvents('foo', transactions) + + expect(events).toEqual([expectedEvent]) + }) + }) + describe('document.editVersion ', () => { + it('edits an existing draft', () => { + const transactions: TransactionLogEventWithEffects[] = [ + { + id: 'edit-draft-tx-1', + timestamp: '2024-10-01T08:20:39.328125Z', + author: 'p8xDvUMxC', + documentIDs: ['drafts.foo'], + effects: { + 'drafts.foo': { + apply: [11, 3, 23, 0, 18, 22, '9', 23, 19, 20, 15, 17, 'new b', 'title'], + revert: [19, 4, 11, 3, 23, 0, 18, 22, '8', 23, 19, 20, 15], + }, + }, + }, + ] + const expectedEvent: EditDocumentVersionEvent = { + id: 'edit-draft-tx-1', + author: 'p8xDvUMxC', + releaseId: undefined, + timestamp: '2024-10-01T08:20:39.328125Z', + type: 'document.editVersion', + versionId: 'drafts.foo', + versionRevisionId: 'edit-draft-tx-1', + } + const events = getDocumentEvents('foo', transactions) + expect(events).toEqual([expectedEvent]) + }) + it('edits an existing draft multiple times within the time window, they are grouped', () => { + // TODO: Confirm this is the expected behavior + const transactions: TransactionLogEventWithEffects[] = [ + { + id: 'edit-draft-tx-3', + timestamp: '2024-10-01T08:20:40.759147Z', + author: 'p8xDvUMxC', + documentIDs: ['drafts.foo'], + effects: { + 'drafts.foo': { + apply: [11, 3, 23, 0, 17, 22, '40', 23, 19, 20, 15, 11, 4, 23, 0, 5, 22, 'ook', 15], + revert: [11, 3, 23, 0, 17, 22, '39', 23, 19, 20, 15, 11, 4, 23, 0, 5, 15], + }, + }, + }, + { + id: 'edit-draft-tx-2', + timestamp: '2024-10-01T08:20:39.328125Z', + author: 'p8xDvUMxC', + documentIDs: ['drafts.foo'], + effects: { + 'drafts.foo': { + apply: [11, 3, 23, 0, 18, 22, '9', 23, 19, 20, 15, 17, 'new b', 'title'], + revert: [19, 4, 11, 3, 23, 0, 18, 22, '8', 23, 19, 20, 15], + }, + }, + }, + ] + const expectedEvent: EditDocumentVersionEvent = { + id: 'edit-draft-tx-3', + type: 'document.editVersion', + timestamp: '2024-10-01T08:20:40.759147Z', + author: 'p8xDvUMxC', + versionId: 'drafts.foo', + versionRevisionId: 'edit-draft-tx-3', + mergedEvents: [ + { + id: 'edit-draft-tx-2', + type: 'document.editVersion', + timestamp: '2024-10-01T08:20:39.328125Z', + author: 'p8xDvUMxC', + versionId: 'drafts.foo', + versionRevisionId: 'edit-draft-tx-2', + }, + ], + } + + const events = getDocumentEvents('foo', transactions) + expect(events).toEqual([expectedEvent]) + + const withAdditionalEvent = getDocumentEvents('foo', [ + { + id: 'edit-draft-tx-4', + timestamp: '2024-10-01T08:20:40.759147Z', + author: 'p8xDvUMxC', + documentIDs: ['drafts.foo'], + effects: { + 'drafts.foo': { + apply: [11, 3, 23, 0, 17, 22, '40', 23, 19, 20, 15, 11, 4, 23, 0, 5, 22, 'ook', 15], + revert: [11, 3, 23, 0, 17, 22, '39', 23, 19, 20, 15, 11, 4, 23, 0, 5, 15], + }, + }, + }, + ...transactions, + ]) + const expectedAdditionalEvent: EditDocumentVersionEvent = { + id: 'edit-draft-tx-4', + type: 'document.editVersion', + timestamp: '2024-10-01T08:20:40.759147Z', + author: 'p8xDvUMxC', + versionId: 'drafts.foo', + versionRevisionId: 'edit-draft-tx-4', + mergedEvents: [ + { + id: 'edit-draft-tx-3', + type: 'document.editVersion', + timestamp: '2024-10-01T08:20:40.759147Z', + author: 'p8xDvUMxC', + versionId: 'drafts.foo', + versionRevisionId: 'edit-draft-tx-3', + }, + { + id: 'edit-draft-tx-2', + type: 'document.editVersion', + timestamp: '2024-10-01T08:20:39.328125Z', + author: 'p8xDvUMxC', + versionId: 'drafts.foo', + versionRevisionId: 'edit-draft-tx-2', + }, + ], + } + + expect(withAdditionalEvent).toEqual([expectedAdditionalEvent]) + }) + }) + describe('document.deleteVersion', () => { + it('deletes a draft, no published version exists', () => { + const transactions = [ + { + id: 'delete-draft-tx', + timestamp: '2024-09-30T15:46:07.630718Z', + author: 'p8xDvUMxC', + documentIDs: ['drafts.foo'], + effects: { + 'drafts.foo': { + apply: [0, null], + revert: [ + 0, + { + _createdAt: '2024-09-30T15:46:01Z', + _id: 'drafts.foo', + _type: 'book', + _updatedAt: '2024-09-30T15:46:01Z', + title: 'delete draft', + }, + ], + }, + }, + }, + { + id: 'create-draft-tx', + timestamp: '2024-09-30T15:46:01.919235Z', + author: 'p8xDvUMxC', + documentIDs: ['drafts.foo'], + effects: { + 'drafts.foo': { + apply: [ + 0, + { + _createdAt: '2024-09-30T15:46:01Z', + _id: 'drafts.foo', + _type: 'book', + _updatedAt: '2024-09-30T15:46:01Z', + title: 'delete draft', + }, + ], + revert: [0, null], + }, + }, + }, + ] + + const expectedEvent: DeleteDocumentVersionEvent = { + id: 'delete-draft-tx', + type: 'document.deleteVersion', + timestamp: '2024-09-30T15:46:07.630718Z', + author: 'p8xDvUMxC', + versionId: 'drafts.foo', + versionRevisionId: 'create-draft-tx', + releaseId: undefined, + } + + const events = getDocumentEvents('foo', transactions) + expect(events).toEqual([ + expectedEvent, + { + id: 'create-draft-tx', + type: 'document.createVersion', + timestamp: '2024-09-30T15:46:01.919235Z', + author: 'p8xDvUMxC', + documentId: 'foo', + versionId: 'drafts.foo', + releaseId: undefined, + versionRevisionId: 'create-draft-tx', + }, + ]) + }) + it('deletes a draft (discard changes), published version exists', () => { + const transactions: TransactionLogEventWithEffects[] = [ + { + id: 'discard-changes-tx', + timestamp: '2024-09-30T16:04:31.096045Z', + author: 'p8xDvUMxC', + documentIDs: ['drafts.foo'], + effects: { + 'drafts.foo': { + apply: [0, null], + revert: [ + 0, + { + _createdAt: '2024-09-30T16:04:07Z', + _id: 'drafts.foo', + _type: 'book', + _updatedAt: '2024-09-30T16:04:07Z', + title: 'creates draft', + }, + ], + }, + }, + }, + { + id: 'creates-draft-2-tx', + timestamp: '2024-09-30T16:04:22.624454Z', + author: 'p8xDvUMxC', + documentIDs: ['drafts.foo'], + effects: { + 'drafts.foo': { + apply: [ + 0, + { + _createdAt: '2024-09-30T16:04:07Z', + _id: 'drafts.foo', + _type: 'book', + _updatedAt: '2024-09-30T16:04:07Z', + title: 'creates draft', + }, + ], + revert: [0, null], + }, + }, + }, + { + id: 'publish-draft-tx', + timestamp: '2024-09-30T16:04:10.258891Z', + author: 'p8xDvUMxC', + documentIDs: ['foo', 'drafts.foo'], + effects: { + 'foo': { + apply: [ + 0, + { + _createdAt: '2024-09-30T16:04:07Z', + _id: 'foo', + _type: 'book', + _updatedAt: '2024-09-30T16:04:07Z', + title: 'delete draft, publish exists', + }, + ], + revert: [0, null], + }, + 'drafts.foo': { + apply: [0, null], + revert: [ + 0, + { + _createdAt: '2024-09-30T16:04:07Z', + _id: 'foo', + _type: 'book', + _updatedAt: '2024-09-30T16:04:07Z', + title: 'delete draft, publish exists', + }, + ], + }, + }, + }, + { + id: 'create-draft-tx', + timestamp: '2024-09-30T16:04:07.646387Z', + author: 'p8xDvUMxC', + documentIDs: ['drafts.foo'], + effects: { + 'drafts.foo': { + apply: [ + 0, + { + _createdAt: '2024-09-30T16:04:07Z', + _id: 'drafts.foo', + _type: 'book', + _updatedAt: '2024-09-30T16:04:07Z', + title: 'delete draft, publish exists', + }, + ], + revert: [0, null], + }, + }, + }, + ] + const expectedEvent: DeleteDocumentVersionEvent = { + id: 'discard-changes-tx', + type: 'document.deleteVersion', + timestamp: '2024-09-30T16:04:31.096045Z', + author: 'p8xDvUMxC', + versionId: 'drafts.foo', + versionRevisionId: 'creates-draft-2-tx', + releaseId: undefined, + } + + const events = getDocumentEvents('foo', transactions) + expect(events).toEqual([ + expectedEvent, + { + id: 'creates-draft-2-tx', + type: 'document.editVersion', + timestamp: '2024-09-30T16:04:22.624454Z', + author: 'p8xDvUMxC', + versionId: 'drafts.foo', + versionRevisionId: 'creates-draft-2-tx', + }, + { + id: 'publish-draft-tx', + timestamp: '2024-09-30T16:04:10.258891Z', + author: 'p8xDvUMxC', + type: 'document.publishVersion', + revisionId: 'publish-draft-tx', + versionId: 'drafts.foo', + versionRevisionId: 'create-draft-tx', + cause: {type: 'document.publish'}, + }, + { + id: 'create-draft-tx', + timestamp: '2024-09-30T16:04:07.646387Z', + author: 'p8xDvUMxC', + type: 'document.createVersion', + documentId: 'foo', + versionId: 'drafts.foo', + versionRevisionId: 'create-draft-tx', + }, + ]) + }) + it.skip('deletes a version', () => {}) + }) + + describe('document.publishVersion', () => { + describe('draft version', () => { + it('publishes a draft', () => { + const transactions: TransactionLogEventWithEffects[] = [ + { + id: 'publish-tx', + timestamp: '2024-09-30T14:00:55.540022Z', + author: 'p8xDvUMxC', + documentIDs: ['foo', 'drafts.foo'], + effects: { + 'foo': { + apply: [ + 0, + { + _createdAt: '2024-09-30T14:00:45Z', + _id: 'foo', + _type: 'author', + _updatedAt: '2024-09-30T14:00:50Z', + name: 'Foo', + }, + ], + revert: [0, null], + }, + 'drafts.foo': { + apply: [0, null], + revert: [ + 0, + { + _createdAt: '2024-09-30T14:00:45Z', + _id: 'foo', + _type: 'author', + _updatedAt: '2024-09-30T14:00:50Z', + name: 'Foo', + }, + ], + }, + }, + }, + { + id: 'create-draft-tx', + timestamp: '2024-09-30T14:00:46.027236Z', + author: 'p8xDvUMxC', + documentIDs: ['drafts.foo'], + effects: { + 'drafts.foo': { + apply: [ + 0, + { + _createdAt: '2024-09-30T14:00:45Z', + _id: 'drafts.foo', + _type: 'author', + _updatedAt: '2024-09-30T14:00:46Z', + name: 'Foo', + }, + ], + revert: [0, null], + }, + }, + }, + ] + const expectedEvent: PublishDocumentVersionEvent = { + id: 'publish-tx', + type: 'document.publishVersion', + timestamp: '2024-09-30T14:00:55.540022Z', + author: 'p8xDvUMxC', + revisionId: 'publish-tx', + releaseId: undefined, + versionId: 'drafts.foo', + versionRevisionId: 'create-draft-tx', + cause: {type: 'document.publish'}, + } + const events = getDocumentEvents('foo', transactions) + expect(events).toEqual([ + expectedEvent, + { + id: 'create-draft-tx', + type: 'document.createVersion', + timestamp: '2024-09-30T14:00:46.027236Z', + author: 'p8xDvUMxC', + documentId: 'foo', + versionId: 'drafts.foo', + releaseId: undefined, + versionRevisionId: 'create-draft-tx', + }, + ]) + }) + it.skip('publishes a scheduled draft', () => {}) + }) + describe.skip('releases version -- not-implemented', () => { + it('publishes a release with no schedule', () => { + // TODO: Implement + // { + // type: 'document.publishVersion', + // timestamp: '2024-09-30T14:00:55.540022Z', + // author: 'p8xDvUMxC', + // revisionId: 'publish-tx', + // releaseId: undefined, + // versionId: 'versions.bar.foo', + // versionRevisionId: undefined, + // cause: {type: 'release.publish'}, + // }, + }) + it('publishes a release with schedule', () => { + // TODO: Implement + // { + // type: 'document.publishVersion', + // timestamp: '2024-09-30T14:00:55.540022Z', + // author: 'p8xDvUMxC', + // revisionId: 'publish-tx', + // releaseId: undefined, + // versionId: 'versions.bar.foo', + // versionRevisionId: undefined, + // cause: {type: 'release.schedule'}, + // }, + }) + }) + }) + describe('document.unpublish', () => { + it('unpublishes a document, no draft exists', () => { + const transactions: TransactionLogEventWithEffects[] = [ + { + id: 'unpublish-tx', + timestamp: '2024-09-30T14:40:02.837538Z', + author: 'p8xDvUMxC', + documentIDs: ['drafts.foo', 'foo'], + effects: { + 'foo': { + apply: [0, null], + revert: [ + 0, + { + _createdAt: '2024-09-30T14:00:45Z', + _id: 'drafts.foo', + _type: 'author', + _updatedAt: '2024-09-30T14:11:51Z', + name: 'Foo 2', + }, + ], + }, + 'drafts.foo': { + apply: [ + 0, + { + _createdAt: '2024-09-30T14:00:45Z', + _id: 'drafts.foo', + _type: 'author', + _updatedAt: '2024-09-30T14:11:51Z', + name: 'Foo 2', + }, + ], + revert: [0, null], + }, + }, + }, + { + id: 'publish-tx', + timestamp: '2024-09-30T14:00:55.540022Z', + author: 'p8xDvUMxC', + documentIDs: ['foo', 'drafts.foo'], + effects: { + 'foo': { + apply: [ + 0, + { + _createdAt: '2024-09-30T14:00:45Z', + _id: 'foo', + _type: 'author', + _updatedAt: '2024-09-30T14:00:50Z', + name: 'Foo', + }, + ], + revert: [0, null], + }, + 'drafts.foo': { + apply: [0, null], + revert: [ + 0, + { + _createdAt: '2024-09-30T14:00:45Z', + _id: 'foo', + _type: 'author', + _updatedAt: '2024-09-30T14:00:50Z', + name: 'Foo', + }, + ], + }, + }, + }, + { + id: 'create-draft-tx', + timestamp: '2024-09-30T14:00:46.027236Z', + author: 'p8xDvUMxC', + documentIDs: ['drafts.foo'], + effects: { + 'drafts.foo': { + apply: [ + 0, + { + _createdAt: '2024-09-30T14:00:45Z', + _id: 'drafts.foo', + _type: 'author', + _updatedAt: '2024-09-30T14:00:46Z', + name: 'Foo', + }, + ], + revert: [0, null], + }, + }, + }, + ] + const events = getDocumentEvents('foo', transactions) + expect(events).toEqual([ + { + id: 'unpublish-tx', + author: 'p8xDvUMxC', + releaseId: undefined, + timestamp: '2024-09-30T14:40:02.837538Z', + type: 'document.unpublish', + versionId: 'drafts.foo', // Is it correct to use the draft version id here, we are creating a draft by unpublishing the document. + versionRevisionId: 'unpublish-tx', // + }, + { + id: 'publish-tx', + type: 'document.publishVersion', + timestamp: '2024-09-30T14:00:55.540022Z', + author: 'p8xDvUMxC', + revisionId: 'publish-tx', + releaseId: undefined, + versionId: 'drafts.foo', + versionRevisionId: 'create-draft-tx', + cause: {type: 'document.publish'}, + }, + { + id: 'create-draft-tx', + type: 'document.createVersion', + timestamp: '2024-09-30T14:00:46.027236Z', + author: 'p8xDvUMxC', + documentId: 'foo', + versionId: 'drafts.foo', + releaseId: undefined, + versionRevisionId: 'create-draft-tx', + }, + ]) + }) + it('unpublishes a document, draft exists', () => { + const transactions: TransactionLogEventWithEffects[] = [ + { + id: 'unpublish-document-tx', + timestamp: '2024-09-30T15:04:37.077740Z', + author: 'p8xDvUMxC', + documentIDs: ['cffbb991'], + effects: { + cffbb991: { + apply: [0, null], + revert: [ + 0, + { + _createdAt: '2024-09-30T15:03:44Z', + _id: 'cffbb991', + _type: 'book', + _updatedAt: '2024-09-30T15:03:45Z', + title: 'a cool book', + }, + ], + }, + }, + }, + { + id: 'edit-draft-tx', + timestamp: '2024-09-30T15:04:29.810025Z', + author: 'p8xDvUMxC', + documentIDs: ['drafts.cffbb991'], + effects: { + 'drafts.cffbb991': { + apply: [ + 11, + 3, + 23, + 0, + 15, + 22, + '4:29', + 23, + 19, + 20, + 15, + 11, + 4, + 23, + 0, + 12, + 22, + 'draft', + 15, + ], + revert: [11, 3, 23, 0, 15, 22, '3:45', 23, 19, 20, 15, 11, 4, 23, 0, 12, 15], + }, + }, + }, + { + id: 'create-draft-2-tx', + timestamp: '2024-09-30T15:04:27.776085Z', + author: 'p8xDvUMxC', + documentIDs: ['drafts.cffbb991'], + effects: { + 'drafts.cffbb991': { + apply: [ + 0, + { + _createdAt: '2024-09-30T15:03:44Z', + _id: 'drafts.cffbb991', + _type: 'book', + _updatedAt: '2024-09-30T15:03:45Z', + title: 'a cool book ', + }, + ], + revert: [0, null], + }, + }, + }, + { + id: 'publish-draft-tx', + timestamp: '2024-09-30T15:03:58.615758Z', + author: 'p8xDvUMxC', + documentIDs: ['cffbb991', 'drafts.cffbb991'], + effects: { + 'cffbb991': { + apply: [ + 0, + { + _createdAt: '2024-09-30T15:03:44Z', + _id: 'cffbb991', + _type: 'book', + _updatedAt: '2024-09-30T15:03:45Z', + title: 'a cool book', + }, + ], + revert: [0, null], + }, + 'drafts.cffbb991': { + apply: [0, null], + revert: [ + 0, + { + _createdAt: '2024-09-30T15:03:44Z', + _id: 'cffbb991', + _type: 'book', + _updatedAt: '2024-09-30T15:03:45Z', + title: 'a cool book', + }, + ], + }, + }, + }, + { + id: 'create-draft-tx', + timestamp: '2024-09-30T15:03:45.061639Z', + author: 'p8xDvUMxC', + documentIDs: ['drafts.cffbb991'], + effects: { + 'drafts.cffbb991': { + apply: [ + 0, + { + _createdAt: '2024-09-30T15:03:44Z', + _id: 'drafts.cffbb991', + _type: 'book', + _updatedAt: '2024-09-30T15:03:45Z', + title: 'a cool book', + }, + ], + revert: [0, null], + }, + }, + }, + ] + const events = getDocumentEvents('cffbb991', transactions) + + expect(events).toEqual([ + { + id: 'unpublish-document-tx', + type: 'document.unpublish', + timestamp: '2024-09-30T15:04:37.077740Z', + author: 'p8xDvUMxC', + versionId: undefined, // Draft already exists, a new draft was not created from this tx + versionRevisionId: undefined, // Draft already exists, a new draft was not created from this tx, no revisionId to assign + releaseId: undefined, + }, + { + id: 'edit-draft-tx', + type: 'document.editVersion', + timestamp: '2024-09-30T15:04:29.810025Z', + author: 'p8xDvUMxC', + releaseId: undefined, + versionId: 'drafts.cffbb991', + versionRevisionId: 'edit-draft-tx', + mergedEvents: [ + { + id: 'create-draft-2-tx', + timestamp: '2024-09-30T15:04:27.776085Z', + author: 'p8xDvUMxC', + type: 'document.editVersion', + versionId: 'drafts.cffbb991', + versionRevisionId: 'create-draft-2-tx', + }, + ], + }, + { + id: 'publish-draft-tx', + type: 'document.publishVersion', + timestamp: '2024-09-30T15:03:58.615758Z', + author: 'p8xDvUMxC', + revisionId: 'publish-draft-tx', + versionId: 'drafts.cffbb991', + versionRevisionId: 'create-draft-tx', + cause: { + type: 'document.publish', + }, + }, + { + id: 'create-draft-tx', + type: 'document.createVersion', + timestamp: '2024-09-30T15:03:45.061639Z', + author: 'p8xDvUMxC', + documentId: 'cffbb991', + versionId: 'drafts.cffbb991', + versionRevisionId: 'create-draft-tx', + }, + ]) + }) + }) + + describe.skip('document.scheduleVersion -- not-implemented', () => { + it('schedules a version to be published, state is pending', () => {}) + it('schedules a version to be published, state is unscheduled', () => {}) + it('schedules a version to be published, state is published', () => {}) + }) + describe.skip('document.unscheduleVersion -- not-implemented', () => { + it('unschedules a version', () => {}) + }) + + describe('document.deleteGroup', () => { + it('deletes a group - only published doc exists', () => { + // TODO: How to distinguish this from from a unpublish transaction if the draft exists. + // They do the same type of operation given the draft is "unedited" it' + const transactions: TransactionLogEventWithEffects[] = [ + { + id: 'NQAO7ykovR2JyvCJEXET8v', + timestamp: '2024-10-01T09:13:02.083217Z', + author: 'p8xDvUMxC', + documentIDs: ['foo'], + effects: { + foo: { + apply: [0, null], + revert: [ + 0, + { + _createdAt: '2024-10-01T09:12:47Z', + _id: 'foo', + _type: 'book', + _updatedAt: '2024-10-01T09:12:48Z', + title: 'delete group, only published doc', + }, + ], + }, + }, + }, + { + id: 'NQAO7ykovR2JyvCJEXEQc9', + timestamp: '2024-10-01T09:12:50.573839Z', + author: 'p8xDvUMxC', + documentIDs: ['foo', 'drafts.foo'], + effects: { + 'foo': { + apply: [ + 0, + { + _createdAt: '2024-10-01T09:12:47Z', + _id: 'foo', + _type: 'book', + _updatedAt: '2024-10-01T09:12:48Z', + title: 'delete group, only published doc', + }, + ], + revert: [0, null], + }, + 'drafts.foo': { + apply: [0, null], + revert: [ + 0, + { + _createdAt: '2024-10-01T09:12:47Z', + _id: 'foo', + _type: 'book', + _updatedAt: '2024-10-01T09:12:48Z', + title: 'delete group, only published doc', + }, + ], + }, + }, + }, + ] + const expectedEvent: DeleteDocumentGroupEvent = { + id: 'NQAO7ykovR2JyvCJEXET8v', + type: 'document.deleteGroup', + timestamp: '2024-10-01T09:13:02.083217Z', + author: 'p8xDvUMxC', + } + const events = getDocumentEvents('foo', transactions) + expect(events).toEqual([ + expectedEvent, + { + id: 'NQAO7ykovR2JyvCJEXEQc9', + type: 'document.publishVersion', + timestamp: '2024-10-01T09:12:50.573839Z', + author: 'p8xDvUMxC', + revisionId: 'NQAO7ykovR2JyvCJEXEQc9', + releaseId: undefined, + versionId: 'drafts.foo', + versionRevisionId: 'not-found', + cause: {type: 'document.publish'}, + }, + ]) + }) + it('deletes a group - only draft doc exists', () => { + // TODO: Confirm we want to have a type: document.deleteVersion in this case + // This uses the discard action + const transactions: TransactionLogEventWithEffects[] = [ + { + id: 'NQAO7ykovR2JyvCJEXQZNp', + timestamp: '2024-10-01T10:19:35.130918Z', + author: 'p8xDvUMxC', + documentIDs: ['drafts.foo'], + effects: { + 'drafts.foo': { + apply: [0, null], + revert: [ + 0, + { + _createdAt: '2024-10-01T10:19:27Z', + _id: 'drafts.foo', + _type: 'book', + _updatedAt: '2024-10-01T10:19:29Z', + title: 'Foo bookj', + }, + ], + }, + }, + }, + { + id: 'e6e8a58d-f926-4743-9db9-122012273f67', + timestamp: '2024-10-01T10:19:29.867625Z', + author: 'p8xDvUMxC', + documentIDs: ['drafts.foo'], + effects: { + 'drafts.foo': { + apply: [11, 3, 23, 0, 18, 22, '9', 23, 19, 20, 15, 17, 'Foo bookj', 'title'], + revert: [11, 3, 23, 0, 18, 22, '8', 23, 19, 20, 15, 11, 4, 23, 0, 4, 15], + }, + }, + }, + ] + const expectedEvent: DeleteDocumentVersionEvent = { + id: 'NQAO7ykovR2JyvCJEXQZNp', + type: 'document.deleteVersion', + timestamp: '2024-10-01T10:19:35.130918Z', + author: 'p8xDvUMxC', + versionId: 'drafts.foo', + versionRevisionId: 'e6e8a58d-f926-4743-9db9-122012273f67', + releaseId: undefined, + } + const events = getDocumentEvents('foo', transactions) + expect(events[0]).toEqual(expectedEvent) + }) + it('deletes a group - draft and published docs exist', () => { + const transactions = [ + { + id: 'Cs9MM9AmleFTukvUAlITNA', + timestamp: '2024-10-01T10:25:50.203497Z', + author: 'p8xDvUMxC', + documentIDs: ['foo', 'drafts.foo'], + effects: { + 'foo': { + apply: [0, null], + revert: [ + 0, + { + _createdAt: '2024-10-01T10:25:33Z', + _id: 'foo', + _type: 'book', + _updatedAt: '2024-10-01T10:25:34Z', + title: 'Foo bar', + }, + ], + }, + 'drafts.foo': { + apply: [0, null], + revert: [ + 0, + { + _createdAt: '2024-10-01T10:25:33Z', + _id: 'drafts.foo', + _type: 'book', + _updatedAt: '2024-10-01T10:25:39Z', + title: 'Foo bar aras', + }, + ], + }, + }, + }, + ] + const expectedEvent: DeleteDocumentGroupEvent = { + id: 'Cs9MM9AmleFTukvUAlITNA', + author: 'p8xDvUMxC', + timestamp: '2024-10-01T10:25:50.203497Z', + type: 'document.deleteGroup', + } + const events = getDocumentEvents('foo', transactions) + expect(events[0]).toEqual(expectedEvent) + }) + it.skip('deletes a group - draft, versions and published docs exist', () => {}) + }) + + describe('document.createLive', () => { + it('creates a live document', () => { + const transactions: TransactionLogEventWithEffects[] = [ + { + id: 'create-live-doc-tx', + timestamp: '2024-09-30T16:15:06.436356Z', + author: 'p8xDvUMxC', + documentIDs: ['foo'], + effects: { + foo: { + apply: [ + 0, + { + _createdAt: '2024-09-30T16:15:05Z', + _id: 'foo', + _type: 'playlist', + _updatedAt: '2024-09-30T16:15:06Z', + }, + ], + revert: [0, null], + }, + }, + }, + ] + const expectedEvent: CreateLiveDocumentEvent = { + id: 'create-live-doc-tx', + type: 'document.createLive', + timestamp: '2024-09-30T16:15:06.436356Z', + author: 'p8xDvUMxC', + revisionId: 'create-live-doc-tx', + documentId: 'foo', + } + const events = getDocumentEvents('foo', transactions) + + expect(events).toEqual([expectedEvent]) + }) + }) + describe('document.updateLive', () => { + it('updates a live document', () => { + const transactions: TransactionLogEventWithEffects[] = [ + { + id: 'update-live-doc-tx', + timestamp: '2024-09-30T16:22:37.797887Z', + author: 'p8xDvUMxC', + documentIDs: ['foo'], + effects: { + foo: { + apply: [11, 3, 23, 0, 18, 22, '7', 23, 19, 20, 15, 17, 'live', 'name'], + revert: [19, 4, 10, 0, 14, '_updatedAt'], + }, + }, + }, + { + id: 'create-live-doc-tx', + timestamp: '2024-09-30T16:22:30.845003Z', + author: 'p8xDvUMxC', + documentIDs: ['foo'], + effects: { + foo: { + apply: [ + 0, + { + _createdAt: '2024-09-30T16:22:30Z', + _id: 'foo', + _type: 'playlist', + _updatedAt: '2024-09-30T16:22:30Z', + }, + ], + revert: [0, null], + }, + }, + }, + ] + const expectedEvent: UpdateLiveDocumentEvent = { + id: 'update-live-doc-tx', + type: 'document.updateLive', + timestamp: '2024-09-30T16:22:37.797887Z', + author: 'p8xDvUMxC', + documentId: 'foo', + revisionId: 'update-live-doc-tx', + } + const events = getDocumentEvents('foo', transactions) + expect(events).toEqual([ + expectedEvent, + { + id: 'create-live-doc-tx', + type: 'document.createLive', + timestamp: '2024-09-30T16:22:30.845003Z', + author: 'p8xDvUMxC', + documentId: 'foo', + revisionId: 'create-live-doc-tx', + }, + ]) + }) + }) + describe('a long chain of transactions, imitating documents lifecycle', () => { + it('creates a draft document, adds some edits, publishes the document, updates the draft and publishes again, then the group is removed', () => { + const transactions: TransactionLogEventWithEffects[] = [ + { + id: '7X3uqAgvtaInRcPnekUfOB', + timestamp: '2024-10-01T13:50:40.265737Z', + author: 'p8xDvUMxC', + documentIDs: ['foo'], + effects: { + foo: { + apply: [0, null], + revert: [ + 0, + { + _createdAt: '2024-10-01T13:50:03Z', + _id: 'foo', + _type: 'book', + _updatedAt: '2024-10-01T13:50:27Z', + title: 'Doing some edits, edits after publish', + }, + ], + }, + }, + }, + { + id: 'hfvKO9BRAN56Oji1mf9vyF', + timestamp: '2024-10-01T13:50:27.113129Z', + author: 'p8xDvUMxC', + documentIDs: ['foo', 'drafts.foo'], + effects: { + 'foo': { + apply: [ + 11, + 3, + 23, + 0, + 17, + 22, + '27', + 23, + 19, + 20, + 15, + 11, + 4, + 23, + 0, + 18, + 22, + 'edits after publish', + 15, + ], + revert: [ + 11, + 3, + 23, + 0, + 17, + 22, + '15', + 23, + 19, + 20, + 15, + 11, + 4, + 23, + 0, + 18, + 22, + 'new more edits', + 15, + ], + }, + 'drafts.foo': { + apply: [0, null], + revert: [ + 0, + { + _createdAt: '2024-10-01T13:50:03Z', + _id: 'foo', + _type: 'book', + _updatedAt: '2024-10-01T13:50:22Z', + title: 'Doing some edits, edits after publish', + }, + ], + }, + }, + }, + { + id: '43322dc5-dd5d-4264-8380-839820114a47', + timestamp: '2024-10-01T13:50:22.074572Z', + author: 'p8xDvUMxC', + documentIDs: ['drafts.foo'], + effects: { + 'drafts.foo': { + apply: [ + 11, + 3, + 23, + 0, + 18, + 22, + '2', + 23, + 19, + 20, + 15, + 11, + 4, + 23, + 0, + 31, + 22, + 'ublish', + 15, + ], + revert: [11, 3, 23, 0, 18, 22, '0', 23, 19, 20, 15, 11, 4, 23, 0, 31, 15], + }, + }, + }, + { + id: '119a88fa-c842-460f-bf95-3f59e8a337cf', + timestamp: '2024-10-01T13:50:20.790669Z', + author: 'p8xDvUMxC', + documentIDs: ['drafts.foo'], + effects: { + 'drafts.foo': { + apply: [ + 11, + 3, + 23, + 0, + 17, + 22, + '20', + 23, + 19, + 20, + 15, + 11, + 4, + 23, + 0, + 19, + 22, + 'dits after p', + 15, + ], + revert: [11, 3, 23, 0, 17, 22, '15', 23, 19, 20, 15, 11, 4, 23, 0, 19, 15], + }, + }, + }, + { + id: 'f2090b01-2652-4022-a00f-1e2bab214feb', + timestamp: '2024-10-01T13:50:19.164999Z', + author: 'p8xDvUMxC', + documentIDs: ['drafts.foo'], + effects: { + 'drafts.foo': { + apply: [ + 0, + { + _createdAt: '2024-10-01T13:50:03Z', + _id: 'drafts.foo', + _type: 'book', + _updatedAt: '2024-10-01T13:50:15Z', + title: 'Doing some edits, e', + }, + ], + revert: [0, null], + }, + }, + }, + { + id: '7X3uqAgvtaInRcPnekUQCf', + timestamp: '2024-10-01T13:50:16.101750Z', + author: 'p8xDvUMxC', + documentIDs: ['foo', 'drafts.foo'], + effects: { + 'foo': { + apply: [ + 0, + { + _createdAt: '2024-10-01T13:50:03Z', + _id: 'foo', + _type: 'book', + _updatedAt: '2024-10-01T13:50:15Z', + title: 'Doing some edits, new more edits', + }, + ], + revert: [0, null], + }, + 'drafts.foo': { + apply: [0, null], + revert: [ + 0, + { + _createdAt: '2024-10-01T13:50:03Z', + _id: 'foo', + _type: 'book', + _updatedAt: '2024-10-01T13:50:15Z', + title: 'Doing some edits, new more edits', + }, + ], + }, + }, + }, + { + id: 'df1015a4-56e3-4e9b-a113-58574d953872', + timestamp: '2024-10-01T13:50:15.326470Z', + author: 'p8xDvUMxC', + documentIDs: ['drafts.foo'], + effects: { + 'drafts.foo': { + apply: [ + 11, + 3, + 23, + 0, + 18, + 22, + '5', + 23, + 19, + 20, + 15, + 11, + 4, + 23, + 0, + 26, + 22, + ' edits', + 15, + ], + revert: [11, 3, 23, 0, 18, 22, '3', 23, 19, 20, 15, 11, 4, 23, 0, 26, 15], + }, + }, + }, + { + id: 'ee2fcd85-c6a7-4edf-81ba-020a09e43249', + timestamp: '2024-10-01T13:50:13.565141Z', + author: 'p8xDvUMxC', + documentIDs: ['drafts.foo'], + effects: { + 'drafts.foo': { + apply: [ + 11, + 3, + 23, + 0, + 18, + 22, + '3', + 23, + 19, + 20, + 15, + 11, + 4, + 23, + 0, + 17, + 22, + ' new more', + 15, + ], + revert: [11, 3, 23, 0, 18, 22, '1', 23, 19, 20, 15, 11, 4, 23, 0, 17, 15], + }, + }, + }, + { + id: '4b6c1788-39d1-4735-9fbf-efba941ea228', + timestamp: '2024-10-01T13:50:11.933594Z', + author: 'p8xDvUMxC', + documentIDs: ['drafts.foo'], + effects: { + 'drafts.foo': { + apply: [11, 3, 23, 0, 17, 22, '11', 23, 19, 20, 15, 11, 4, 23, 0, 16, 22, ',', 15], + revert: [11, 3, 23, 0, 17, 22, '07', 23, 19, 20, 15, 11, 4, 23, 0, 16, 15], + }, + }, + }, + { + id: 'df84dbb2-a525-4535-ac0d-1e47452f87c4', + timestamp: '2024-10-01T13:50:07.215155Z', + author: 'p8xDvUMxC', + documentIDs: ['drafts.foo'], + effects: { + 'drafts.foo': { + apply: [11, 3, 23, 0, 18, 22, '7', 23, 19, 20, 15, 11, 4, 23, 0, 14, 22, 'ts', 15], + revert: [11, 3, 23, 0, 18, 22, '5', 23, 19, 20, 15, 11, 4, 23, 0, 14, 15], + }, + }, + }, + { + id: 'cf73dd44-c9fb-4277-93b1-1c7295fa4f91', + timestamp: '2024-10-01T13:50:05.765932Z', + author: 'p8xDvUMxC', + documentIDs: ['drafts.foo'], + effects: { + 'drafts.foo': { + apply: [11, 3, 23, 0, 18, 22, '5', 23, 19, 20, 15, 17, 'Doing some edi', 'title'], + revert: [19, 4, 11, 3, 23, 0, 18, 22, '4', 23, 19, 20, 15], + }, + }, + }, + { + id: '7f789263-e111-4e43-826e-c4f98013b531', + timestamp: '2024-10-01T13:50:04.125782Z', + author: 'p8xDvUMxC', + documentIDs: ['drafts.foo'], + effects: { + 'drafts.foo': { + apply: [ + 0, + { + _createdAt: '2024-10-01T13:50:03Z', + _id: 'drafts.foo', + _type: 'book', + _updatedAt: '2024-10-01T13:50:04Z', + }, + ], + revert: [0, null], + }, + }, + }, + ] + const events = getDocumentEvents('foo', transactions) + + const expectedEvents: DocumentGroupEvent[] = [ + { + id: '7X3uqAgvtaInRcPnekUfOB', + timestamp: '2024-10-01T13:50:40.265737Z', + author: 'p8xDvUMxC', + type: 'document.deleteGroup', + }, + { + id: 'hfvKO9BRAN56Oji1mf9vyF', + timestamp: '2024-10-01T13:50:27.113129Z', + author: 'p8xDvUMxC', + type: 'document.publishVersion', + revisionId: 'hfvKO9BRAN56Oji1mf9vyF', + versionId: 'drafts.foo', + versionRevisionId: '43322dc5-dd5d-4264-8380-839820114a47', + cause: { + type: 'document.publish', + }, + }, + { + id: '43322dc5-dd5d-4264-8380-839820114a47', + timestamp: '2024-10-01T13:50:22.074572Z', + author: 'p8xDvUMxC', + type: 'document.editVersion', + versionId: 'drafts.foo', + versionRevisionId: '43322dc5-dd5d-4264-8380-839820114a47', + mergedEvents: [ + { + id: '119a88fa-c842-460f-bf95-3f59e8a337cf', + timestamp: '2024-10-01T13:50:20.790669Z', + author: 'p8xDvUMxC', + type: 'document.editVersion', + versionId: 'drafts.foo', + versionRevisionId: '119a88fa-c842-460f-bf95-3f59e8a337cf', + }, + { + id: 'f2090b01-2652-4022-a00f-1e2bab214feb', + timestamp: '2024-10-01T13:50:19.164999Z', + author: 'p8xDvUMxC', + type: 'document.editVersion', + versionId: 'drafts.foo', + versionRevisionId: 'f2090b01-2652-4022-a00f-1e2bab214feb', + }, + ], + }, + { + id: '7X3uqAgvtaInRcPnekUQCf', + timestamp: '2024-10-01T13:50:16.101750Z', + author: 'p8xDvUMxC', + type: 'document.publishVersion', + revisionId: '7X3uqAgvtaInRcPnekUQCf', + versionId: 'drafts.foo', + versionRevisionId: 'df1015a4-56e3-4e9b-a113-58574d953872', + cause: { + type: 'document.publish', + }, + }, + { + id: 'df1015a4-56e3-4e9b-a113-58574d953872', + timestamp: '2024-10-01T13:50:15.326470Z', + author: 'p8xDvUMxC', + type: 'document.editVersion', + versionId: 'drafts.foo', + versionRevisionId: 'df1015a4-56e3-4e9b-a113-58574d953872', + mergedEvents: [ + { + id: 'ee2fcd85-c6a7-4edf-81ba-020a09e43249', + timestamp: '2024-10-01T13:50:13.565141Z', + author: 'p8xDvUMxC', + type: 'document.editVersion', + versionId: 'drafts.foo', + versionRevisionId: 'ee2fcd85-c6a7-4edf-81ba-020a09e43249', + }, + { + id: '4b6c1788-39d1-4735-9fbf-efba941ea228', + timestamp: '2024-10-01T13:50:11.933594Z', + author: 'p8xDvUMxC', + type: 'document.editVersion', + versionId: 'drafts.foo', + versionRevisionId: '4b6c1788-39d1-4735-9fbf-efba941ea228', + }, + { + id: 'df84dbb2-a525-4535-ac0d-1e47452f87c4', + timestamp: '2024-10-01T13:50:07.215155Z', + author: 'p8xDvUMxC', + type: 'document.editVersion', + versionId: 'drafts.foo', + versionRevisionId: 'df84dbb2-a525-4535-ac0d-1e47452f87c4', + }, + { + id: 'cf73dd44-c9fb-4277-93b1-1c7295fa4f91', + timestamp: '2024-10-01T13:50:05.765932Z', + author: 'p8xDvUMxC', + type: 'document.editVersion', + versionId: 'drafts.foo', + versionRevisionId: 'cf73dd44-c9fb-4277-93b1-1c7295fa4f91', + }, + ], + }, + { + id: '7f789263-e111-4e43-826e-c4f98013b531', + timestamp: '2024-10-01T13:50:04.125782Z', + author: 'p8xDvUMxC', + type: 'document.createVersion', + documentId: 'foo', + versionId: 'drafts.foo', + versionRevisionId: '7f789263-e111-4e43-826e-c4f98013b531', + }, + ] + expect(events).toEqual(expectedEvents) + }) + it('creates a draft document, adds some edits, publishes the doc, then the document is unpublished, draft is removed, finally draft is restored', () => { + const transactions: TransactionLogEventWithEffects[] = [ + { + id: 'NQAO7ykovR2JyvCJEYUfaz', + timestamp: '2024-10-01T13:57:18.716920Z', + author: 'p8xDvUMxC', + documentIDs: ['drafts.foo'], + effects: { + 'drafts.foo': { + apply: [ + 0, + { + _createdAt: '2024-10-01T13:56:00Z', + _id: 'drafts.foo', + _type: 'book', + _updatedAt: '2024-10-01T13:57:18Z', + title: 'Foo 12', + }, + ], + revert: [0, null], + }, + }, + }, + { + id: '7X3uqAgvtaInRcPnekY9ep', + timestamp: '2024-10-01T13:57:02.426734Z', + author: 'p8xDvUMxC', + documentIDs: ['drafts.foo'], + effects: { + 'drafts.foo': { + apply: [0, null], + revert: [ + 0, + { + _createdAt: '2024-10-01T13:56:00Z', + _id: 'drafts.foo', + _type: 'book', + _updatedAt: '2024-10-01T13:56:03Z', + title: 'Foo 12', + }, + ], + }, + }, + }, + { + id: 'Cs9MM9AmleFTukvUAmGEQ4', + timestamp: '2024-10-01T13:56:25.700407Z', + author: 'p8xDvUMxC', + documentIDs: ['drafts.foo', 'foo'], + effects: { + 'foo': { + apply: [0, null], + revert: [ + 0, + { + _createdAt: '2024-10-01T13:56:00Z', + _id: 'drafts.foo', + _type: 'book', + _updatedAt: '2024-10-01T13:56:03Z', + title: 'Foo 12', + }, + ], + }, + 'drafts.foo': { + apply: [ + 0, + { + _createdAt: '2024-10-01T13:56:00Z', + _id: 'drafts.foo', + _type: 'book', + _updatedAt: '2024-10-01T13:56:03Z', + title: 'Foo 12', + }, + ], + revert: [0, null], + }, + }, + }, + { + id: '7X3uqAgvtaInRcPnekXqDV', + timestamp: '2024-10-01T13:56:19.108493Z', + author: 'p8xDvUMxC', + documentIDs: ['foo', 'drafts.foo'], + effects: { + 'foo': { + apply: [ + 0, + { + _createdAt: '2024-10-01T13:56:00Z', + _id: 'foo', + _type: 'book', + _updatedAt: '2024-10-01T13:56:03Z', + title: 'Foo 12', + }, + ], + revert: [0, null], + }, + 'drafts.foo': { + apply: [0, null], + revert: [ + 0, + { + _createdAt: '2024-10-01T13:56:00Z', + _id: 'foo', + _type: 'book', + _updatedAt: '2024-10-01T13:56:03Z', + title: 'Foo 12', + }, + ], + }, + }, + }, + { + id: 'a9953800-b9ef-4744-9f83-4b86caa3f988', + timestamp: '2024-10-01T13:56:03.479036Z', + author: 'p8xDvUMxC', + documentIDs: ['drafts.foo'], + effects: { + 'drafts.foo': { + apply: [11, 3, 23, 0, 18, 22, '3', 23, 19, 20, 15, 11, 4, 23, 0, 5, 22, '2', 15], + revert: [11, 3, 23, 0, 18, 22, '2', 23, 19, 20, 15, 11, 4, 23, 0, 5, 15], + }, + }, + }, + { + id: '6affadb3-925e-4705-a1a5-34f8258cbd14', + timestamp: '2024-10-01T13:56:02.073790Z', + author: 'p8xDvUMxC', + documentIDs: ['drafts.foo'], + effects: { + 'drafts.foo': { + apply: [11, 3, 23, 0, 18, 22, '2', 23, 19, 20, 15, 17, 'Foo 1', 'title'], + revert: [10, 0, 14, '_updatedAt', 11, 4, 23, 0, 4, 15], + }, + }, + }, + { + id: '14e5c10d-e003-42ed-a289-785dd4d1c0d3', + timestamp: '2024-10-01T13:56:00.375209Z', + author: 'p8xDvUMxC', + documentIDs: ['drafts.foo'], + effects: { + 'drafts.foo': { + apply: [ + 0, + { + _createdAt: '2024-10-01T13:56:00Z', + _id: 'drafts.foo', + _type: 'book', + _updatedAt: '2024-10-01T13:56:00Z', + title: 'Foo ', + }, + ], + revert: [0, null], + }, + }, + }, + ] + const events = getDocumentEvents('foo', transactions) + + const expectedEvents: DocumentGroupEvent[] = [ + { + id: 'NQAO7ykovR2JyvCJEYUfaz', + timestamp: '2024-10-01T13:57:18.716920Z', + author: 'p8xDvUMxC', + // TODO: Consider having a "document.restoreVersion" event? + type: 'document.createVersion', + documentId: 'foo', + versionId: 'drafts.foo', + versionRevisionId: 'NQAO7ykovR2JyvCJEYUfaz', + }, + { + id: '7X3uqAgvtaInRcPnekY9ep', + timestamp: '2024-10-01T13:57:02.426734Z', + author: 'p8xDvUMxC', + type: 'document.deleteVersion', + versionId: 'drafts.foo', + versionRevisionId: 'Cs9MM9AmleFTukvUAmGEQ4', + }, + { + id: 'Cs9MM9AmleFTukvUAmGEQ4', + timestamp: '2024-10-01T13:56:25.700407Z', + author: 'p8xDvUMxC', + type: 'document.unpublish', + versionId: 'drafts.foo', + versionRevisionId: 'Cs9MM9AmleFTukvUAmGEQ4', + }, + { + id: '7X3uqAgvtaInRcPnekXqDV', + timestamp: '2024-10-01T13:56:19.108493Z', + author: 'p8xDvUMxC', + type: 'document.publishVersion', + revisionId: '7X3uqAgvtaInRcPnekXqDV', + versionId: 'drafts.foo', + versionRevisionId: 'a9953800-b9ef-4744-9f83-4b86caa3f988', + cause: { + type: 'document.publish', + }, + }, + { + id: 'a9953800-b9ef-4744-9f83-4b86caa3f988', + timestamp: '2024-10-01T13:56:03.479036Z', + author: 'p8xDvUMxC', + type: 'document.editVersion', + versionId: 'drafts.foo', + versionRevisionId: 'a9953800-b9ef-4744-9f83-4b86caa3f988', + mergedEvents: [ + { + id: '6affadb3-925e-4705-a1a5-34f8258cbd14', + timestamp: '2024-10-01T13:56:02.073790Z', + author: 'p8xDvUMxC', + type: 'document.editVersion', + versionId: 'drafts.foo', + versionRevisionId: '6affadb3-925e-4705-a1a5-34f8258cbd14', + }, + ], + }, + { + id: '14e5c10d-e003-42ed-a289-785dd4d1c0d3', + timestamp: '2024-10-01T13:56:00.375209Z', + author: 'p8xDvUMxC', + type: 'document.createVersion', + documentId: 'foo', + versionId: 'drafts.foo', + versionRevisionId: '14e5c10d-e003-42ed-a289-785dd4d1c0d3', + }, + ] + expect(events).toEqual(expectedEvents) + }) + it('creates a live editable document and do edits on it, then it is removed', () => { + const transactions: TransactionLogEventWithEffects[] = [ + { + id: '7X3uqAgvtaInRcPnekknt5', + timestamp: '2024-10-01T14:18:42.658609Z', + author: 'p8xDvUMxC', + documentIDs: ['foo'], + effects: { + foo: { + apply: [0, null], + revert: [ + 0, + { + _createdAt: '2024-10-01T14:17:56Z', + _id: 'foo', + _type: 'playlist', + _updatedAt: '2024-10-01T14:18:05Z', + name: 'live editing this is now saved', + }, + ], + }, + }, + }, + { + id: 'f7e370f2-0996-4f58-8ae7-ac9b074d4b2d', + timestamp: '2024-10-01T14:18:05.245942Z', + author: 'p8xDvUMxC', + documentIDs: ['foo'], + effects: { + foo: { + apply: [11, 3, 23, 0, 18, 22, '5', 23, 19, 20, 15, 11, 4, 23, 0, 25, 22, 'saved', 15], + revert: [11, 3, 23, 0, 18, 22, '4', 23, 19, 20, 15, 11, 4, 23, 0, 25, 15], + }, + }, + }, + { + id: '68cb1bd2-da1f-4680-b904-4c8663ef1978', + timestamp: '2024-10-01T14:18:04.238826Z', + author: 'p8xDvUMxC', + documentIDs: ['foo'], + effects: { + foo: { + apply: [ + 11, + 3, + 23, + 0, + 18, + 22, + '4', + 23, + 19, + 20, + 15, + 11, + 4, + 23, + 0, + 14, + 22, + 'his is now ', + 15, + ], + revert: [11, 3, 23, 0, 18, 22, '3', 23, 19, 20, 15, 11, 4, 23, 0, 14, 15], + }, + }, + }, + { + id: '9b84c71f-5757-4be9-a687-141a5ad60787', + timestamp: '2024-10-01T14:18:03.193480Z', + author: 'p8xDvUMxC', + documentIDs: ['foo'], + effects: { + foo: { + apply: [11, 3, 23, 0, 18, 22, '3', 23, 19, 20, 15, 11, 4, 23, 0, 13, 22, 't', 15], + revert: [11, 3, 23, 0, 18, 22, '1', 23, 19, 20, 15, 11, 4, 23, 0, 13, 15], + }, + }, + }, + { + id: '584182b2-2bc3-4808-a03d-da18774702b5', + timestamp: '2024-10-01T14:18:01.900710Z', + author: 'p8xDvUMxC', + documentIDs: ['foo'], + effects: { + foo: { + apply: [11, 3, 23, 0, 15, 22, '8:01', 23, 19, 20, 15, 11, 4, 23, 0, 12, 22, ' ', 15], + revert: [11, 3, 23, 0, 15, 22, '7:59', 23, 19, 20, 15, 11, 4, 23, 0, 12, 15], + }, + }, + }, + { + id: 'd7791764-2204-4a7e-89e3-3d6a6f2434d7', + timestamp: '2024-10-01T14:17:59.547949Z', + author: 'p8xDvUMxC', + documentIDs: ['foo'], + effects: { + foo: { + apply: [11, 3, 23, 0, 18, 22, '9', 23, 19, 20, 15, 11, 4, 22, 'l', 23, 0, 11, 15], + revert: [11, 3, 23, 0, 18, 22, '8', 23, 19, 20, 15, 11, 4, 23, 1, 12, 15], + }, + }, + }, + { + id: 'b15933c6-0691-4436-8d75-b8bdfc4ec6eb', + timestamp: '2024-10-01T14:17:58.508566Z', + author: 'p8xDvUMxC', + documentIDs: ['foo'], + effects: { + foo: { + apply: [17, 'ive editing', 'name'], + revert: [19, 4], + }, + }, + }, + { + id: '1d551112-0866-480b-844a-ca370e86a95a', + timestamp: '2024-10-01T14:17:58.047179Z', + author: 'p8xDvUMxC', + documentIDs: ['foo'], + effects: { + foo: { + apply: [ + 0, + { + _createdAt: '2024-10-01T14:17:56Z', + _id: 'foo', + _type: 'playlist', + _updatedAt: '2024-10-01T14:17:58Z', + }, + ], + revert: [0, null], + }, + }, + }, + ] + const events = getDocumentEvents('foo', transactions) + const expectedEvents = [ + { + id: '7X3uqAgvtaInRcPnekknt5', + timestamp: '2024-10-01T14:18:42.658609Z', + author: 'p8xDvUMxC', + type: 'document.deleteGroup', + }, + { + id: 'f7e370f2-0996-4f58-8ae7-ac9b074d4b2d', + timestamp: '2024-10-01T14:18:05.245942Z', + author: 'p8xDvUMxC', + type: 'document.updateLive', + documentId: 'foo', + revisionId: 'f7e370f2-0996-4f58-8ae7-ac9b074d4b2d', + }, + { + id: '68cb1bd2-da1f-4680-b904-4c8663ef1978', + timestamp: '2024-10-01T14:18:04.238826Z', + author: 'p8xDvUMxC', + type: 'document.updateLive', + documentId: 'foo', + revisionId: '68cb1bd2-da1f-4680-b904-4c8663ef1978', + }, + { + id: '9b84c71f-5757-4be9-a687-141a5ad60787', + timestamp: '2024-10-01T14:18:03.193480Z', + author: 'p8xDvUMxC', + type: 'document.updateLive', + documentId: 'foo', + revisionId: '9b84c71f-5757-4be9-a687-141a5ad60787', + }, + { + id: '584182b2-2bc3-4808-a03d-da18774702b5', + timestamp: '2024-10-01T14:18:01.900710Z', + author: 'p8xDvUMxC', + type: 'document.updateLive', + documentId: 'foo', + revisionId: '584182b2-2bc3-4808-a03d-da18774702b5', + }, + { + id: 'd7791764-2204-4a7e-89e3-3d6a6f2434d7', + timestamp: '2024-10-01T14:17:59.547949Z', + author: 'p8xDvUMxC', + type: 'document.updateLive', + documentId: 'foo', + revisionId: 'd7791764-2204-4a7e-89e3-3d6a6f2434d7', + }, + { + id: 'b15933c6-0691-4436-8d75-b8bdfc4ec6eb', + timestamp: '2024-10-01T14:17:58.508566Z', + author: 'p8xDvUMxC', + type: 'document.updateLive', + documentId: 'foo', + revisionId: 'b15933c6-0691-4436-8d75-b8bdfc4ec6eb', + }, + { + id: '1d551112-0866-480b-844a-ca370e86a95a', + timestamp: '2024-10-01T14:17:58.047179Z', + author: 'p8xDvUMxC', + type: 'document.createLive', + documentId: 'foo', + revisionId: '1d551112-0866-480b-844a-ca370e86a95a', + }, + ] + + expect(events).toEqual(expectedEvents) + }) + }) +}) diff --git a/packages/sanity/src/core/store/events/getDocumentEvents.ts b/packages/sanity/src/core/store/events/getDocumentEvents.ts new file mode 100644 index 00000000000..5f6711c9f45 --- /dev/null +++ b/packages/sanity/src/core/store/events/getDocumentEvents.ts @@ -0,0 +1,357 @@ +import { + type MendozaEffectPair, + type MendozaPatch, + type TransactionLogEventWithEffects, +} from '@sanity/types' + +import {getDraftId, getPublishedId, getVersionFromId} from '../../util/draftUtils' +import {type Transaction} from '../_legacy/history/history/types' +import {type DocumentGroupEvent} from './types' + +type EffectState = 'unedited' | 'deleted' | 'upsert' | 'created' + +// Similar to https://github.com/sanity-io/sanity/blob/events-api-studio/packages/sanity/src/core/store/_legacy/history/history/chunker.ts#L67 +function getEffectState(effect?: MendozaEffectPair): EffectState { + const modified = Boolean(effect) + const deleted = effect && isDeletePatch(effect?.apply) + // New concept. How to read the "creation" if not like this? + const created = effect && isDeletePatch(effect?.revert) + + if (deleted) { + return 'deleted' + } + if (created) { + return 'created' + } + + if (modified) { + return 'upsert' + } + + return 'unedited' +} + +/** + * The document we should look at in the transaction + */ +type DocumentToMapTheAction = 'draft' | 'published' | 'none' + +type DocumentEventType = DocumentGroupEvent['type'] | 'no-effect' | 'maybeUnpublishMaybeDelete' + +/** + * | **Publish is** | **Draft is created** | **Draft is deleted** | **Draft is unedited** | **Draft is upsert** | + * |---------------------------|-------------------------------------|---------------------------------------|---------------------------------------|-------------------------------------| + * | **unedited** | document.createVersion (draft) | document.deleteVersion (draft) | no-effect (none) | document.editVersion (draft) | + * | **deleted** | document.unpublish (published) | document.deleteGroup (published) | maybeUnpublishMaybeDelete (published) | document.unpublish (published) | + * | **upsert** | document.updateLive (published) | document.publishVersion (published) | document.updateLive (published) | document.updateLive (published) | + * | **created** | document.createVersion (published) | document.publishVersion (published) | document.createLive (published) | document.createVersion (published) | + */ + +const STATE_MAP: { + [publishState in EffectState]: { + [draftState in EffectState]: { + type: DocumentEventType + document: DocumentToMapTheAction + } + } +} = { + // Publish is: + unedited: { + // & Draft is: + created: {type: 'document.createVersion', document: 'draft'}, + deleted: {type: 'document.deleteVersion', document: 'draft'}, + upsert: {type: 'document.editVersion', document: 'draft'}, + unedited: {type: 'no-effect', document: 'none'}, + }, + // Publish is: + deleted: { + // & Draft is: + created: {type: 'document.unpublish', document: 'published'}, + deleted: {type: 'document.deleteGroup', document: 'published'}, + unedited: {type: 'maybeUnpublishMaybeDelete', document: 'published'}, + upsert: {type: 'document.unpublish', document: 'published'}, + }, + // Publish is: + upsert: { + // & Draft is: + created: {type: 'document.updateLive', document: 'published'}, + deleted: {type: 'document.publishVersion', document: 'published'}, + unedited: {type: 'document.updateLive', document: 'published'}, + upsert: {type: 'document.updateLive', document: 'published'}, + }, + // Publish is: + created: { + // & Draft is: + created: {type: 'document.createVersion', document: 'published'}, // Should be document: both?? + deleted: {type: 'document.publishVersion', document: 'published'}, + unedited: {type: 'document.createLive', document: 'published'}, + upsert: {type: 'document.createVersion', document: 'published'}, + }, +} + +function isDeletePatch(patch: MendozaPatch): boolean { + return patch[0] === 0 && patch[1] === null +} + +/** + * @internal + * @beta + * This might change, don't use. + * This function receives a transaction and returns a document group event. + * Assumes the user is viewing the published document with only drafts. Versions are not yet supported here + */ +export function getEventFromTransaction( + documentId: string, + transaction: Transaction, + previousTransactions: Transaction[], +): DocumentGroupEvent { + const base = { + id: transaction.id, + timestamp: transaction.timestamp, + author: transaction.author, + } + + const draftId = getDraftId(documentId) + const publishedId = getPublishedId(documentId) + const draftState = getEffectState(transaction.draftEffect) + const publishedState = getEffectState(transaction.publishedEffect) + const getDocumentEvent = ( + type: DocumentEventType, + document: DocumentToMapTheAction, + ): DocumentGroupEvent => { + switch (type) { + case 'document.createVersion': { + if (document === 'draft') { + const lastDraftTransaction = previousTransactions.find((t) => t.draftEffect) + const wasDraftDeleted = + lastDraftTransaction?.draftEffect && // Modified the draft + isDeletePatch(lastDraftTransaction.draftEffect?.apply) && // Deleted the draft + !lastDraftTransaction.publishedEffect // No changes to publish doc, so it was not a publish action. + + const draftExisted = previousTransactions.some((t) => Boolean(t.draftEffect)) + + if (draftExisted && !wasDraftDeleted) { + return getDocumentEvent('document.editVersion', document) + } + } + return { + ...base, + type, + + documentId: documentId, + versionId: document === 'draft' ? draftId : publishedId, + releaseId: getVersionFromId(documentId), + versionRevisionId: transaction.id, + } + } + case 'document.editVersion': { + return { + ...base, + type, + + releaseId: getVersionFromId(documentId), + versionId: draftId, + versionRevisionId: transaction.id, + } + } + + case 'document.deleteVersion': { + return { + ...base, + type, + + versionId: document === 'draft' ? draftId : publishedId, + // The revision id of the last edit in the draft document + versionRevisionId: + previousTransactions.find((t) => Boolean(t.draftEffect))?.id || 'not-found', + releaseId: getVersionFromId(documentId), + } + } + + case 'document.publishVersion': { + return { + ...base, + type, + + revisionId: transaction.id, + releaseId: getVersionFromId(documentId), + versionId: draftId, // TODO: How to get the version in case of releases + versionRevisionId: + previousTransactions.find((t) => Boolean(t.draftEffect))?.id || 'not-found', + cause: { + type: 'document.publish', // TODO: How to get the `release.publish` and the `release.schedule` events? + }, + } + } + + case 'document.unpublish': { + return { + ...base, + type, + + // The version that will be created by this unpublish action, e.g. drafts.foo + versionId: transaction.draftEffect ? draftId : undefined, + versionRevisionId: transaction.draftEffect ? transaction.id : undefined, + releaseId: getVersionFromId(documentId), + } + } + case 'document.createLive': { + return { + ...base, + type, + documentId: publishedId, + revisionId: transaction.id, + } + } + case 'document.updateLive': { + return { + ...base, + type, + documentId: publishedId, + revisionId: transaction.id, + } + } + + case 'document.deleteGroup': { + return { + ...base, + type, + } + } + case 'maybeUnpublishMaybeDelete': { + // In this tx, published is deleted and draft unedited. + // We need to determine if draft at this point exists or not. + const lastDraftTransaction = previousTransactions.find((t) => t.draftEffect) + if (!lastDraftTransaction) { + // Draft doesn't exist and it was not created, so it was a delete action + return getDocumentEvent('document.deleteGroup', document) + } + const wasDraftDeletedOrPublished = + lastDraftTransaction.draftEffect && // Modified the draft + isDeletePatch(lastDraftTransaction.draftEffect?.apply) + + if (wasDraftDeletedOrPublished) { + // Draft was either deleted or published, so it doesn't exist, no draft was created, so it's a delete action. + return getDocumentEvent('document.deleteGroup', document) + } + return getDocumentEvent('document.unpublish', document) + } + + // The following are not implemented yet - We don't yet have the concept of scheduling versions. + case 'document.scheduleVersion': { + return { + ...base, + type, + // @ts-expect-error this is not implemented yet + 'not-implemented': true, + } + } + case 'document.unscheduleVersion': { + return { + ...base, + type, + // @ts-expect-error this is not implemented yet + 'not-implemented': true, + } + } + + default: { + console.error(`Unhandled event type: ${type}`) + return { + ...base, + type: 'document.editVersion', + releaseId: getVersionFromId(documentId), + versionId: draftId, + versionRevisionId: transaction.id, + } + } + } + } + + const {type, document} = STATE_MAP[publishedState][draftState] + + return getDocumentEvent(type, document) +} + +const MERGE_WINDOW = 5 * 60 * 1000 // 5 minutes + +function isWithinMergeWindow(a: string, b: string) { + return Math.abs(Date.parse(a) - Date.parse(b)) < MERGE_WINDOW +} + +const mergeEvents = (events: DocumentGroupEvent[]): DocumentGroupEvent[] => { + const result = [] + + for (const event of events) { + if (result.length === 0) { + // If result is empty, add the current event + result.push(event) + } else { + const lastEvent = result[result.length - 1] + + if ( + lastEvent.type === 'document.editVersion' && + event.type === 'document.editVersion' && + isWithinMergeWindow(lastEvent.timestamp, event.timestamp) + ) { + // Merge the current event into the last event's merged array + if (!lastEvent.mergedEvents) { + lastEvent.mergedEvents = [] + } + lastEvent.mergedEvents.push(event) + } else { + // If the time difference is greater than the window, add as a new event + result.push(event) + } + } + } + + return result +} + +/** + * @internal + * @beta + */ +export const addTransactionEffect = ( + documentId: string, + transaction: TransactionLogEventWithEffects, + index: number, +): Transaction => { + const draftEffect = transaction.effects[getDraftId(documentId)] + const publishedEffect = transaction.effects[getPublishedId(documentId)] + return { + index, + id: transaction.id, + timestamp: transaction.timestamp, + author: transaction.author, + draftEffect, + publishedEffect, + } +} + +/** + * @internal + * @beta + * + * This function receives a list of transactions that can be fetched from CL transactions API {@link https://www.sanity.io/docs/history-api#45ac5eece4ca} + * It is intended at least now, to support fetching the transactions for the published and draft document and builds the + * document group events from the response. + */ +export function getDocumentEvents( + documentId: string, + transactions: TransactionLogEventWithEffects[], +): DocumentGroupEvent[] { + const events = transactions.map((transaction, index) => { + // The transactions are ordered from newest to oldest, so we can slice the array from the current index + const previousTransactions = transactions.slice(index + 1) + + return getEventFromTransaction( + documentId, + addTransactionEffect(documentId, transaction, index), + previousTransactions.map((tx, i) => addTransactionEffect(documentId, tx, i)), + ) + }) + + return mergeEvents(events) +} diff --git a/packages/sanity/src/core/store/events/index.ts b/packages/sanity/src/core/store/events/index.ts new file mode 100644 index 00000000000..ec312f415bc --- /dev/null +++ b/packages/sanity/src/core/store/events/index.ts @@ -0,0 +1,2 @@ +export * from './getDocumentEvents' +export {type DocumentVersionEventType} from './types' diff --git a/packages/sanity/src/core/store/events/types.ts b/packages/sanity/src/core/store/events/types.ts new file mode 100644 index 00000000000..b75c32d51f5 --- /dev/null +++ b/packages/sanity/src/core/store/events/types.ts @@ -0,0 +1,283 @@ +/* eslint-disable tsdoc/syntax */ +/** + * # Draft model + * + * The following describes the semantics of the draft model in Content Releases. + * + * ## Terminology + * + * In this world we have the following terms: + * + * - "Document" is unfortunately an overloaded term. It _may_ refer to the + * user's perspective of a document in Studio It _may_ refer to a specific + * document as observed through the API, or it _may_ refer to user's + * perspective of a document in Studio (which is a single "document group" + * represented by multiple documents). + * - "Document version" is a document with the ID of `drafts.` or `versions.{bundleId}.` + * - "Document group" is an explicit way of referring to the published + * document and all of its versions. + * - "Event" (either on "a document" or on "a release") represents a change in the + * state. They are often caused by actions, but they are not 1-to-1. The + * "publish release" action causes a `ScheduleDocumentVersionEvent` for each + * of the document versions inside the release. + * + * These are higher level events and you can not assume that they are being + * caused by a single document actions. For instance, scheduling/publishing a + * _release_ causes a `ScheduleDocumentEvent` to appear in the document's event + * list. + * + * ## Document group event + * + * The completely lifecycle of a document group can be described with a series + * of _events_. These are the higher level changes such as "document was + * published", "version was created", "document was scheduled", and so forth. + * Every event has a single timestamp. + * + * We're also using the following conventions: + * + * - `documentId` always refers to the published document ID (which is also what + * we consider the ID for the whole group). + * - `revisionId` refers to a revision on the published document. + * - `versionId` refers to a document version ID. + * - `releaseId` refers to the release of the document version. + * This will be not present if `versionId` starts with`drafts.`. + * - `versionRevisionId` refers to a revision on a document version. + * + * See {@link DocumentGroupEvent} for the full list of events. + * + * ## Document changes + * + * Interestingly, there's no document group events about the _contents_ of a + * document. Instead we have a separate concept of _document changes_ which are + * the actual changes of the attributes to a document. + * + * Document changes are constructed from edits (i.e. through the Edit action), + * but are distinct objects. They have a time_span_, instead of a time_stamp_, + * and can have multiple authors and/or fields modified in a single "change". + * The change could be represented by "these fields have been modifed in some + * way" or "here's a detailed attribution of every new character that appeared + * in this Portable Text". + * + * ## Release events + * + * There's a separate set of events for releases which deals with changes done + * at the whole release level (e.g. schedule/publish) that are _critical_ for + * its behavior. These events intentionally do not include changes to + * non-critical metadata (e.g. title). This is currently not defined here. + * + * ## Release activity + * + * When looking at the complete activity of a release it should be composed of + * three different sources: + * + * 1. Release events (schedule/unschedule/publish etc). + * 2. Release metadata changes. + * 2. Document group events related to release – with the exclusion of events + * which are caused by release-level actions. + * + * ## Relation to Content Lake APIs + * + * "Document group events", "document changes" and "release events" are + * currently not exposed by the REST API in Content Lake. Some of these data + * _might_ however already be inferred through using the History Transactions + * API. + * + * The intention is for Studio to internally refer to these concepts using an + * implementation which uses the _current_ Content Lake APIs. Over time we + * aspire to extend the API to provide access to this data natively and + * efficiently. + * + * ## Overall document lifecycle + * + * The overall document's existence is defined by the existance of either the + * published document or a draft (either the main draft or a version in a + * release). + * + * This means that there are two ways a document can be _created_: + * + * 1. `CreateDocumentVersionEvent`: This is what Studio does through an Edit action. + * 2. `CreateLiveDocumentEvent`: A raw Create mutation sent outside of the Studio. + * + * The whole document is considered _deleted_ through a single event: + * + * 1. `DeleteDocumentGroupEvent`: This is caused either by the Delete action, + * or when discarding the last draft. + * + * ## Version lifecycle + * + * A document version has the following lifecycle: + * + * 1. "Version doesn't exist". + * - `CreateDocumentVersionEvent`: Edit action => "Version exists" + * - `UnpublishDocumentEvent`: Unpublish action => "Version exists" + * 2. "Version exists". + * - `DeleteDocumentVersionEvent`: DiscardDraft action => "Version doesn't + * exist" + * - `PublishDocumentVersionEvent`: Publish document/release action => "Version doesn't exist" + * - `ScheduleDocumentVersionEvent`: Schedule release action => "Version is scheduled" + * - `DeleteDocumentGroupEvent`: Delete action _OR_ DiscardDraft [the last one] => "Version doesn't exist" + * 3. "Version is scheduled". + * - `PublishDocumentVersionEvent`: Automatically, on schedule => "Version doesn't exist" + * - `UnscheduleDocumentVersionEvent`: Unschedule release action => "Version exists" + * + * ## Published lifecycle + * + * The published document has the following lifecycle: + * + * 1. "Published document doesn't exist". + * - `PublishDocumentVersionEvent`: Publish document/release action => "Published document exists". + * - `CreateLiveDocumentEvent`: Raw Create mutation => "Published document exists" + * 2. "Published document exists" + * - `PublishDocumentVersionEvent`: Publish document/release action => "Published document exists" + * - `UnpublishDocumentEvent`: Unpublish action => "Published document doesn't exist" + * - `DeleteDocumentGroupEvent`: Delete action => "Published document doesn't exist" + * - `UpdateLiveDocumentEvent`: Raw Update mutation => "Published document exists" + */ + +/** + * Events relevant for the whole document group. + * @internal + * @beta + **/ +export type DocumentGroupEvent = + | CreateDocumentVersionEvent + | EditDocumentVersionEvent + | DeleteDocumentVersionEvent + | PublishDocumentVersionEvent + | UnpublishDocumentEvent + | ScheduleDocumentVersionEvent + | UnscheduleDocumentVersionEvent + | DeleteDocumentGroupEvent + | CreateLiveDocumentEvent + | UpdateLiveDocumentEvent + +/** + * @internal + * @beta + **/ +export type DocumentVersionEventType = DocumentGroupEvent['type'] + +/** + * A generic event with a type and a timestamp. + */ +interface BaseEvent { + /** + * The id of the transaction that generated this event, is the same as the `_rev` the documents that were affected by this event will have. + **/ + id: string + timestamp: string + // Moved author to baseEvent. + author: string +} + +/** + * The critical events related to the state of a release. + */ +export type ReleaseEvent = any + +export interface CreateDocumentVersionEvent extends BaseEvent { + type: 'document.createVersion' + documentId: string + + releaseId?: string + versionId: string + versionRevisionId: string +} + +// TODO: This is a new event type not listed in the original document. +export interface EditDocumentVersionEvent extends BaseEvent { + type: 'document.editVersion' + + releaseId?: string + versionId: string + versionRevisionId: string + mergedEvents?: EditDocumentVersionEvent[] +} + +export interface DeleteDocumentVersionEvent extends BaseEvent { + type: 'document.deleteVersion' + + releaseId?: string + versionId: string + versionRevisionId: string +} + +export interface PublishDocumentVersionEvent extends BaseEvent { + type: 'document.publishVersion' + + revisionId: string + + versionId: string + releaseId?: string + + /** This is only available when it was triggered by Publish action. */ + versionRevisionId?: string + + /** What caused this document to be published. */ + cause: PublishCause +} + +// TODO: Author was removed from here, moved to the BaseEvent object +export type PublishCause = + | { + // The document was explicitly published. + type: 'document.publish' + // author: string + } + | { + // The whole release was explicitly published. + type: 'release.publish' + // author: string + } + | { + // The whole release was published through a schedule. + type: 'release.schedule' + scheduledAt: string + // author: string + } + +export interface UnpublishDocumentEvent extends BaseEvent { + type: 'document.unpublish' + + /** The version that was created based on it */ + versionId: string | undefined + versionRevisionId?: string + releaseId?: string +} + +export interface ScheduleDocumentVersionEvent extends BaseEvent { + type: 'document.scheduleVersion' + + releaseId: string + versionId: string + versionRevisionId: string + + /** The _current_ state of this schedule. */ + state: 'pending' | 'unscheduled' | 'published' + + publishAt: string +} + +export interface UnscheduleDocumentVersionEvent extends BaseEvent { + type: 'document.unscheduleVersion' + + releaseId: string + versionId: string + versionRevisionId: string +} + +export interface DeleteDocumentGroupEvent extends BaseEvent { + type: 'document.deleteGroup' +} + +export interface CreateLiveDocumentEvent extends BaseEvent { + type: 'document.createLive' + documentId: string + revisionId: string +} + +export interface UpdateLiveDocumentEvent extends BaseEvent { + type: 'document.updateLive' + documentId: string + revisionId: string +} diff --git a/packages/sanity/src/core/store/index.ts b/packages/sanity/src/core/store/index.ts index 3373bb62f9d..33c2a778b85 100644 --- a/packages/sanity/src/core/store/index.ts +++ b/packages/sanity/src/core/store/index.ts @@ -1,2 +1,3 @@ export * from './_legacy' +export * from './events' export * from './user' diff --git a/packages/sanity/src/structure/panes/document/timeline/__workshop__/DocumentGroupEvent.tsx b/packages/sanity/src/structure/panes/document/timeline/__workshop__/DocumentGroupEvent.tsx new file mode 100644 index 00000000000..4936e65d88c --- /dev/null +++ b/packages/sanity/src/structure/panes/document/timeline/__workshop__/DocumentGroupEvent.tsx @@ -0,0 +1,319 @@ +/* eslint-disable max-nested-callbacks */ +/* eslint-disable react/jsx-no-bind */ +import {type TransactionLogEventWithEffects} from '@sanity/types' +import {Box, Card, Code, Container, Flex, Text, TextInput, useToast} from '@sanity/ui' +import {useMemo, useState} from 'react' +import {useObservable} from 'react-rx' +import {catchError, map, type Observable, of, startWith, Subject, switchMap} from 'rxjs' +import { + addTransactionEffect, + type Chunk, + chunkFromTransaction, + getDocumentEvents, + getDraftId, + getPublishedId, + mergeChunk, + useClient, +} from 'sanity' + +import {Button} from '../../../../../ui-components' +import {Timeline} from '../timeline' + +const query = { + excludeContent: 'true', + includeIdentifiedDocumentsOnly: 'true', + tag: 'sanity.studio.structure.transactions', + effectFormat: 'mendoza', + excludeMutations: 'true', + reverse: 'true', + limit: '50', +} + +const refresh$ = new Subject() + +export default function DocumentEvents() { + const [documentId, setDocumentId] = useState('') + const client = useClient() + const [selectedEventId, setSelectedEventId] = useState('') + + const transactions$: Observable = useMemo(() => { + const dataset = client.config().dataset + if (!documentId) return of([]) + + const ids = [getPublishedId(documentId), getDraftId(documentId)] + + return refresh$.pipe( + startWith(undefined), + switchMap(() => + client.observable + .request({url: `/data/history/${dataset}/transactions/${ids.join(',')}?`, query}) + .pipe( + map((result) => + result + .toString('utf8') + .split('\n') + .filter(Boolean) + .map((line: string) => JSON.parse(line)), + ), + startWith([]), + catchError((err) => { + console.error(err) + return of([]) + }), + ), + ), + ) + }, [client, documentId]) + + const transactions = useObservable(transactions$, []) + const events = useMemo( + () => getDocumentEvents(getPublishedId(documentId), transactions), + [documentId, transactions], + ) + + const chunks: Chunk[] = useMemo(() => { + const publishedId = getPublishedId(documentId) + // Transactions for chunks have to be from first to last + const fromFirstToLastTransactions = transactions.slice().reverse() + const allTransactionsWithEffects = fromFirstToLastTransactions.map((t, index) => + addTransactionEffect(publishedId, t, index), + ) + return fromFirstToLastTransactions + .map((transaction, index) => { + return chunkFromTransaction( + publishedId, + addTransactionEffect(publishedId, transaction, index), + allTransactionsWithEffects, + ) + }) + .reduce((acc: Chunk[], chunk) => { + if (acc.length === 0) { + acc.push(chunk) + return acc + } + const merged = mergeChunk(acc[acc.length - 1], chunk) + + if (Array.isArray(merged)) { + return [...acc, chunk] + } + return [...acc.slice(0, -1), merged] + }, []) + .reverse() + .map((chunk, idx) => ({ + ...chunk, + index: -idx, + })) + }, [documentId, transactions]) + + const selectedEvents = useMemo(() => { + if (!selectedEventId) return [] + const event = events.find((e) => { + if (e.type === 'document.editVersion' && e.mergedEvents) { + // See if the selected event is a merged event + return e.id === selectedEventId || e.mergedEvents.some((me) => me.id === selectedEventId) + } + return e.id === selectedEventId + }) + if (event?.type === 'document.editVersion') { + return [event.id, event.mergedEvents?.map((e) => e.id)].flat() + } + return [event?.id].filter(Boolean) + }, [events, selectedEventId]) + const selectedChunk = chunks.find((e) => selectedEvents.includes(e.event.id)) + + const handleSelectItem = (chunk: Chunk) => { + const {event} = chunk + setSelectedEventId(event.id) + // Find the node with `event.id` and scroll it into view + const eventNode = document.getElementById(`event-${event.id}`) + if (eventNode) { + eventNode.scrollIntoView({behavior: 'instant', block: 'start'}) + } + + setTimeout(() => { + const transactionNode = document.getElementById(`transaction-${event.id}`) + if (transactionNode) { + transactionNode.scrollIntoView({behavior: 'instant', block: 'start'}) + } + }, 50) + + setTimeout(() => { + const chunkNode = document.getElementById(`chunk-${event.id}`) + if (chunkNode) { + chunkNode.scrollIntoView({behavior: 'instant', block: 'start'}) + } + }, 100) + } + + const toast = useToast() + return ( + +
{ + e.preventDefault() + refresh$.next() + }} + > + + setDocumentId(e.currentTarget.value)} + placeholder="Add document id" + style={{minWidth: '360px'}} + /> +