@sanity/transaction-collator
Version:
Collate events from a series of transactions
244 lines (218 loc) • 8.29 kB
text/typescript
import {isEqual, uniq} from 'lodash'
import {EventType, HistoryEvent, Transaction, Mutation} from './types'
import {ndjsonToArray} from './utils/ndjsonToArray'
const EDIT_EVENT_TIME_TRESHHOLD_MS = 1000 * 60 * 5 // 5 minutes
export function transactionsToEvents(
documentIds: string[],
transactions: string | Buffer | Transaction[]
): HistoryEvent[] {
const rawItems = Array.isArray(transactions) ? transactions : ndjsonToArray(transactions)
return (
rawItems
// Make sure we only deal with transactions that are relevant for our documents
.filter(
(transaction: Transaction) =>
transaction.documentIDs && transaction.documentIDs.some((id) => documentIds.includes(id))
)
// ensure transactions are sorted by time
.sort(compareTimestamp)
// Turn a transaction into a classified HistoryEvent
.map((transaction, index) => mapToEvents(transaction, documentIds, index))
// Chunk and group edit events
.reduce(reduceEdits, [])
// Manipulate truncation events to be able to restore to published version
.reduce(createReduceTruncatedFn(), [])
)
}
function mapToEvents(transaction: Transaction, documentIds: string[], index = 0): HistoryEvent {
const {type, documentId} = mutationsToEventTypeAndDocumentId(
filterRelevantMutations(transaction.mutations, documentIds),
index
)
const timestamp = transaction.timestamp
const userIds = findUserIds(transaction, type)
return {
type,
documentIDs: transaction.documentIDs,
displayDocumentId: documentId,
rev: transaction.id,
userIds,
transactionIds: [transaction.id],
startTime: timestamp,
endTime: timestamp,
}
}
function reduceEdits(
acc: HistoryEvent[],
current: HistoryEvent,
index: number,
arr: HistoryEvent[]
) {
const nextEvent = arr[index + 1]
const skipEvent =
current.type === 'edited' &&
nextEvent &&
nextEvent.type === 'edited' &&
new Date(nextEvent.endTime).getTime() - new Date(current.endTime).getTime() <
EDIT_EVENT_TIME_TRESHHOLD_MS &&
isEqual(current.documentIDs, nextEvent.documentIDs)
if (skipEvent) {
// Lift authors over to next event
nextEvent.userIds = uniq(nextEvent.userIds.concat(current.userIds))
// Lift list of transactions over to next event
nextEvent.transactionIds = uniq(current.transactionIds.concat(nextEvent.transactionIds))
// Set startTime on next event to be this one if not done already
// (then startTime and endTime would be different)
if (current.startTime === current.endTime) {
nextEvent.startTime = current.startTime
}
} else {
acc.push(current)
}
return acc
}
function createReduceTruncatedFn() {
let truncated: HistoryEvent[] | undefined
return (acc: HistoryEvent[], current: HistoryEvent, index: number, arr: HistoryEvent[]) => {
truncated = truncated || arr.filter((event) => event.type === 'truncated')
if (!truncated.includes(current)) {
acc.push(current)
}
if (index === arr.length - 1) {
const draftTruncationEvent = truncated.find(
(evt) => !!evt.displayDocumentId && evt.displayDocumentId.startsWith('drafts.')
)
const publishedTruncationEvent = truncated.find(
(evt) => !!evt.displayDocumentId && !evt.displayDocumentId.startsWith('drafts.')
)
if (draftTruncationEvent && publishedTruncationEvent) {
acc.unshift({...draftTruncationEvent, type: 'edited'})
acc.unshift(publishedTruncationEvent)
} else if (publishedTruncationEvent) {
acc.unshift(publishedTruncationEvent)
} else if (draftTruncationEvent) {
acc.unshift(draftTruncationEvent)
}
}
return acc
}
}
export function mutationsToEventTypeAndDocumentId(
mutations: Mutation[],
transactionIndex: number
): {type: EventType; documentId: string | null} {
const withoutPatches = mutations.filter((mut) => mut.patch === undefined)
const createOrReplaceMutation = withoutPatches.find((mut) => mut.createOrReplace !== undefined)
const createOrReplacePatch = createOrReplaceMutation && createOrReplaceMutation.createOrReplace
const createMutation = withoutPatches.find((mut) => mut.create !== undefined)
const createPatch = createMutation && createMutation.create
const createIfNotExistsMutation = withoutPatches.find(
(mut) => mut.createIfNotExists !== undefined
)
const createIfNotExistsPatch =
createIfNotExistsMutation && createIfNotExistsMutation.createIfNotExists
const deleteMutation = withoutPatches.find((mut) => mut.delete !== undefined)
const deletePatch = deleteMutation && deleteMutation.delete
const squashedMutation = withoutPatches.find((mut) => mut.createSquashed !== undefined)
const squashedPatch = squashedMutation && squashedMutation.createSquashed
const createValue = createOrReplacePatch || createPatch || createIfNotExistsPatch
// Created
if (transactionIndex === 0) {
const type = 'created'
if (createOrReplacePatch) {
return {type, documentId: createOrReplacePatch._id}
}
if (createIfNotExistsPatch) {
return {type, documentId: createIfNotExistsPatch._id}
}
if (createPatch) {
return {type, documentId: createPatch._id}
}
}
// (re) created
if (transactionIndex > 0 && mutations.length === 1 && createIfNotExistsPatch) {
const type = createIfNotExistsPatch._id.startsWith('.draft') ? 'edited' : 'published'
return {type, documentId: createIfNotExistsPatch._id}
}
// Published
if (
(createOrReplacePatch || createPatch || createIfNotExistsPatch) &&
deletePatch &&
deletePatch.id.startsWith('drafts.')
) {
return {
type: 'published',
documentId: (createValue && createValue._id) || null,
}
}
// Unpublished
if (
withoutPatches.length === 2 &&
(createIfNotExistsPatch || createPatch) &&
deletePatch &&
!deletePatch.id.startsWith('drafts.')
) {
return {
type: 'unpublished',
documentId: (createValue && createValue._id) || null,
}
}
// Restored to previous version
if (
(createOrReplacePatch && createOrReplacePatch._id.startsWith('drafts.')) ||
(createPatch && createPatch._id.startsWith('drafts.')) ||
(createIfNotExistsPatch && createIfNotExistsPatch._id.startsWith('drafts.'))
) {
return {
type: 'edited',
documentId: (createValue && createValue._id) || null,
}
}
// Discard drafted changes
if (mutations.length === 1 && deletePatch && deletePatch.id.startsWith('drafts.')) {
return {type: 'discardDraft', documentId: deletePatch.id.replace('drafts.', '')}
}
// Truncated history
if (mutations.length === 1 && squashedPatch) {
return {type: 'truncated', documentId: squashedPatch.document._id}
}
// Deleted
if (mutations.every((mut) => mut.delete !== undefined)) {
return {type: 'deleted', documentId: null}
}
// Edited
const patchedMutation = mutations.find((mut) => mut.patch !== undefined)
if (patchedMutation && patchedMutation.patch) {
return {type: 'edited', documentId: patchedMutation.patch.id}
}
// Edited (createOrReplace)
if (createOrReplacePatch) {
return {type: 'edited', documentId: createOrReplacePatch._id}
}
return {type: 'unknown', documentId: null}
}
function findUserIds(transaction: Transaction, type: EventType): string[] {
// The truncated event is kind of special
if (type === 'truncated') {
const createSquasedMut = transaction.mutations.find((mut) => mut.createSquashed !== undefined)
const createSquasedPatch = createSquasedMut && createSquasedMut.createSquashed
if (createSquasedPatch) {
return createSquasedPatch.authors
}
}
// Default is to return the transaction author
return [transaction.author]
}
function compareTimestamp(a: Transaction, b: Transaction) {
return new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()
}
function filterRelevantMutations(mutations: Mutation[], documentIds: string[]) {
return mutations.filter((mut) => {
return Object.keys(mut)
.map((key) => {
const val = (mut as any)[key]
return val.id || val._id || (val.document && val.document._id) || false
})
.some((id) => id && documentIds.includes(id))
})
}