@sanity/sdk
Version:
787 lines (744 loc) • 26.3 kB
text/typescript
import {type SanityDocument} from '@sanity/types'
import {parse} from 'groq-js'
import {Subject} from 'rxjs'
import {describe, expect, it} from 'vitest'
import {getDraftId, getPublishedId} from '../utils/ids'
import {type DocumentEvent} from './events'
import {type RemoteDocument} from './listen'
import {type DocumentSet} from './processMutations'
import {
addSubscriptionIdToDocument,
type AppliedTransaction,
applyFirstQueuedTransaction,
applyRemoteDocument,
batchAppliedTransactions,
cleanupOutgoingTransaction,
type QueuedTransaction,
queueTransaction,
removeQueuedTransaction,
removeSubscriptionIdFromDocument,
revertOutgoingTransaction,
type SyncTransactionState,
transitionAppliedTransactionsToOutgoing,
} from './reducers'
const grants = {
create: parse('true'),
update: parse('true'),
read: parse('true'),
history: parse('true'),
}
const exampleDoc: SanityDocument = {
_createdAt: new Date().toISOString(),
_updatedAt: new Date().toISOString(),
_type: 'author',
_id: 'doc1',
_rev: 'txn0',
}
describe('queueTransaction', () => {
it('adds the transaction to queued and adds subscription ids to each document state', () => {
const initialState: SyncTransactionState = {
queued: [],
applied: [],
outgoing: undefined,
grants,
documentStates: {},
}
const transaction: QueuedTransaction = {
transactionId: 'txn1',
actions: [
{
type: 'document.edit',
documentId: 'doc1',
documentType: 'book',
patches: [{set: {foo: 'bar'}}],
},
],
}
const newState = queueTransaction(initialState, transaction)
expect(newState.queued).toHaveLength(1)
expect(newState.queued[0]).toEqual(transaction)
// Check that both the published and draft documentStates got a subscription id added.
const draftId = getDraftId('doc1')
const pubId = getPublishedId('doc1')
expect(newState.documentStates[draftId]).toBeDefined()
expect(newState.documentStates[pubId]).toBeDefined()
expect(newState.documentStates[draftId]?.subscriptions).toContain('txn1')
expect(newState.documentStates[pubId]?.subscriptions).toContain('txn1')
})
})
describe('removeQueuedTransaction', () => {
it('removes the transaction from queued and removes subscription ids from documents', () => {
const draftId = getDraftId('doc1')
const pubId = getPublishedId('doc1')
const initialState: SyncTransactionState = {
queued: [
{
transactionId: 'txn1',
actions: [
{
type: 'document.edit',
documentId: 'doc1',
documentType: 'book',
patches: [{set: {foo: 'bar'}}],
},
],
},
],
applied: [],
outgoing: undefined,
grants,
documentStates: {
[draftId]: {id: draftId, subscriptions: ['txn1'], local: {}},
[pubId]: {id: pubId, subscriptions: ['txn1'], local: {}},
} as SyncTransactionState['documentStates'],
}
const newState = removeQueuedTransaction(initialState, 'txn1')
expect(newState.queued).toHaveLength(0)
// Because removing the only subscription causes the document state to be omitted
expect(newState.documentStates[draftId]).toBeUndefined()
expect(newState.documentStates[pubId]).toBeUndefined()
})
it('returns the same state if the transaction is not found', () => {
const state: SyncTransactionState = {
queued: [],
applied: [],
outgoing: undefined,
grants,
documentStates: {},
}
const newState = removeQueuedTransaction(state, 'nonexistent')
expect(newState).toEqual(state)
})
})
describe('applyFirstQueuedTransaction', () => {
it('returns unchanged state if no queued transaction exists', () => {
const state: SyncTransactionState = {
queued: [],
applied: [],
outgoing: undefined,
grants,
documentStates: {},
}
const newState = applyFirstQueuedTransaction(state)
expect(newState).toEqual(state)
})
it('returns unchanged state if any required document is not yet loaded', () => {
// If a document's local value is undefined, the reducer should do nothing.
const draftId = getDraftId('doc1')
const state: SyncTransactionState = {
queued: [
{
transactionId: 'txn2',
actions: [{type: 'document.discard', documentId: 'doc1', documentType: 'book'}],
},
],
applied: [],
outgoing: undefined,
grants,
documentStates: {
[draftId]: {id: draftId, subscriptions: ['txn2'], local: undefined},
},
}
const newState = applyFirstQueuedTransaction(state)
expect(newState).toEqual(state)
})
it('returns unchanged state if grants are missing', () => {
const state: SyncTransactionState = {
queued: [
{
transactionId: 'txn-missing-grants',
actions: [{type: 'document.discard', documentId: 'doc1', documentType: 'book'}],
},
],
applied: [],
outgoing: undefined,
// No grants provided.
documentStates: {},
}
const newState = applyFirstQueuedTransaction(state)
expect(newState).toEqual(state)
})
it('applies the first queued transaction (using a discard action)', () => {
// For a discard action, processActions deletes the draft document.
const draftId = getDraftId('doc1')
const pubId = getPublishedId('doc1')
const initialDraft = {...exampleDoc, _id: draftId, foo: 'bar', _rev: 'rev1'}
const initialPub = {...exampleDoc, _id: pubId, foo: 'bar', _rev: 'rev1'}
const state: SyncTransactionState = {
queued: [
{
transactionId: 'txn3',
actions: [{type: 'document.discard', documentId: 'doc1', documentType: 'book'}],
},
],
applied: [],
outgoing: undefined,
grants,
documentStates: {
[draftId]: {id: draftId, subscriptions: ['txn3'], local: initialDraft},
[pubId]: {id: pubId, subscriptions: ['txn3'], local: initialPub},
} as SyncTransactionState['documentStates'],
}
const newState = applyFirstQueuedTransaction(state)
// The queued array should now be empty.
expect(newState.queued).toHaveLength(0)
// One applied transaction should have been added.
expect(newState.applied).toHaveLength(1)
// For a discard action, the draft's local value becomes null.
expect(newState.documentStates[draftId]?.local).toBeNull()
// The published version remains unchanged.
expect(newState.documentStates[pubId]?.local).toEqual(initialPub)
})
})
describe('batchAppliedTransactions', () => {
it('returns undefined if no applied transactions are provided', () => {
const result = batchAppliedTransactions([])
expect(result).toBeUndefined()
})
it('returns the transaction with batching disabled if it has > 1 action', () => {
const appliedTx: AppliedTransaction = {
transactionId: 'txn4',
actions: [
{
type: 'document.edit',
documentId: 'doc1',
documentType: 'book',
patches: [{set: {foo: 'a'}}],
},
{
type: 'document.edit',
documentId: 'doc1',
documentType: 'book',
patches: [{set: {bar: 'b'}}],
},
],
disableBatching: false,
outgoingActions: [],
outgoingMutations: [],
base: {[getDraftId('doc1')]: {_id: getDraftId('doc1'), _rev: 'rev1'}} as DocumentSet,
working: {
[getDraftId('doc1')]: {
...exampleDoc,
_id: getDraftId('doc1'),
_rev: 'rev2',
foo: 'a',
bar: 'b',
},
},
previous: {[getDraftId('doc1')]: {...exampleDoc, _id: getDraftId('doc1'), _rev: 'rev1'}},
previousRevs: {[getDraftId('doc1')]: 'rev1'},
timestamp: '2025-02-06T00:00:00.000Z',
}
const result = batchAppliedTransactions([appliedTx])
expect(result).toBeDefined()
expect(result?.disableBatching).toBe(true)
expect(result?.batchedTransactionIds).toEqual(['txn4'])
})
it('batches two edit transactions when possible', () => {
const draftId = getDraftId('doc1')
const appliedTx1: AppliedTransaction = {
transactionId: 'txn5',
actions: [
{
type: 'document.edit',
documentId: 'doc1',
documentType: 'book',
patches: [{set: {foo: 'a'}}],
},
],
disableBatching: false,
outgoingActions: [
{
actionType: 'sanity.action.document.edit',
draftId,
publishedId: getPublishedId('doc1'),
patch: {set: {foo: 'a'}},
},
],
outgoingMutations: [],
base: {[draftId]: {...exampleDoc, _id: draftId, _rev: 'rev1'}},
working: {[draftId]: {...exampleDoc, _id: draftId, _rev: 'rev2', foo: 'a'}},
previous: {[draftId]: {...exampleDoc, _id: draftId, _rev: 'rev1'}},
previousRevs: {[draftId]: 'rev1'},
timestamp: '2025-02-06T00:00:00.000Z',
}
const appliedTx2: AppliedTransaction = {
transactionId: 'txn6',
actions: [
{
type: 'document.edit',
documentId: 'doc1',
documentType: 'book',
patches: [{set: {bar: 'b'}}],
},
],
disableBatching: false,
outgoingActions: [
{
actionType: 'sanity.action.document.edit',
draftId,
publishedId: getPublishedId('doc1'),
patch: {set: {bar: 'b'}},
},
],
outgoingMutations: [],
base: {[draftId]: {...exampleDoc, _id: draftId, _rev: 'rev2'}},
working: {[draftId]: {...exampleDoc, _id: draftId, _rev: 'rev3', foo: 'a', bar: 'b'}},
previous: {[draftId]: {...exampleDoc, _id: draftId, _rev: 'rev2'}},
previousRevs: {[draftId]: 'rev2'},
timestamp: '2025-02-06T00:01:00.000Z',
}
const batched = batchAppliedTransactions([appliedTx1, appliedTx2])
expect(batched).toBeDefined()
expect(batched?.disableBatching).toBe(false)
expect(batched?.batchedTransactionIds).toEqual(['txn5', 'txn6'])
expect(batched?.actions).toEqual([appliedTx1.actions[0], appliedTx2.actions[0]])
// Outgoing actions are concatenated.
expect(batched?.outgoingActions).toEqual([
...appliedTx1.outgoingActions,
...appliedTx2.outgoingActions,
])
})
it('returns a transaction with disableBatching true if a single edit action already has disableBatching set', () => {
const draftId = getDraftId('docA')
const appliedTx: AppliedTransaction = {
transactionId: 'txn-disable',
actions: [
{
type: 'document.edit',
documentId: 'docA',
documentType: 'book',
patches: [{set: {foo: 'a'}}],
},
],
disableBatching: true, // already set to true
outgoingActions: [],
outgoingMutations: [],
base: {[draftId]: {...exampleDoc, _id: draftId, _rev: 'rev1'}},
working: {[draftId]: {...exampleDoc, _id: draftId, foo: 'a', _rev: 'rev2'}},
previous: {[draftId]: {...exampleDoc, _id: draftId, _rev: 'rev1'}},
previousRevs: {[draftId]: 'rev1'},
timestamp: '2025-02-06T00:00:00.000Z',
}
const result = batchAppliedTransactions([appliedTx])
expect(result).toBeDefined()
expect(result?.disableBatching).toBe(true)
expect(result?.batchedTransactionIds).toEqual(['txn-disable'])
// The actions array should be the same as the input's.
expect(result?.actions).toEqual(appliedTx.actions)
})
})
describe('transitionAppliedTransactionsToOutgoing', () => {
it('returns the same state if an outgoing transaction already exists', () => {
const state: SyncTransactionState = {
queued: [],
applied: [],
outgoing: {transactionId: 'txnOut'} as SyncTransactionState['outgoing'],
grants,
documentStates: {},
}
const newState = transitionAppliedTransactionsToOutgoing(state)
expect(newState).toEqual(state)
})
it('transitions applied transactions to an outgoing transaction', () => {
const draftId = getDraftId('doc1')
const initialDoc = {...exampleDoc, _id: draftId, foo: 'old', _rev: 'rev1'}
const state: SyncTransactionState = {
queued: [],
applied: [
{
transactionId: 'txn7',
actions: [
{
type: 'document.edit',
documentId: 'doc1',
documentType: 'book',
patches: [{set: {foo: 'new'}}],
},
],
disableBatching: false,
outgoingActions: [],
outgoingMutations: [],
base: {[draftId]: initialDoc},
working: {[draftId]: {...exampleDoc, _id: draftId, foo: 'new', _rev: 'rev2'}},
previous: {[draftId]: initialDoc},
previousRevs: {[draftId]: 'rev1'},
timestamp: '2025-02-06T00:02:00.000Z',
},
],
outgoing: undefined,
grants,
documentStates: {
[draftId]: {
...exampleDoc,
id: draftId,
subscriptions: ['sub1'],
local: {...exampleDoc, _id: draftId, foo: 'new', _rev: 'rev2'},
},
},
}
const newState = transitionAppliedTransactionsToOutgoing(state)
expect(newState.outgoing).toBeDefined()
expect(newState.applied).toHaveLength(0)
const docState = newState.documentStates[draftId]
expect(docState?.unverifiedRevisions).toBeDefined()
expect(docState?.unverifiedRevisions?.['txn7']).toBeDefined()
expect(docState?.unverifiedRevisions?.['txn7']?.previousRev).toEqual('rev1')
})
})
describe('cleanupOutgoingTransaction', () => {
it('returns unchanged state if there is no outgoing transaction', () => {
const state: SyncTransactionState = {
queued: [],
applied: [],
outgoing: undefined,
grants,
documentStates: {},
}
const newState = cleanupOutgoingTransaction(state)
expect(newState).toEqual(state)
})
it('removes subscription ids for all documents associated with the outgoing transaction and then clears outgoing', () => {
const draftId = getDraftId('doc1')
const pubId = getPublishedId('doc1')
const state: SyncTransactionState = {
queued: [],
applied: [],
outgoing: {
transactionId: 'txn8',
actions: [
{
type: 'document.edit',
documentId: 'doc1',
documentType: 'book',
patches: [{set: {foo: 'x'}}],
},
],
disableBatching: false,
batchedTransactionIds: ['txn8'],
outgoingActions: [],
outgoingMutations: [],
base: {},
working: {},
previous: {},
previousRevs: {},
timestamp: '2025-02-06T00:03:00.000Z',
},
grants,
documentStates: {
[draftId]: {...exampleDoc, id: draftId, subscriptions: ['txn8'], local: null},
[pubId]: {...exampleDoc, id: pubId, subscriptions: ['txn8'], local: null},
},
}
const newState = cleanupOutgoingTransaction(state)
expect(newState.outgoing).toBeUndefined()
// Since the only subscription was removed, the document states should be omitted.
expect(newState.documentStates[draftId]).toBeUndefined()
expect(newState.documentStates[pubId]).toBeUndefined()
})
})
describe('revertOutgoingTransaction', () => {
it('reverts the outgoing transaction and updates documentStates by removing unverified revisions', () => {
const draftId = getDraftId('doc1')
const pubId = getPublishedId('doc1')
// In this test we simulate a state with one applied transaction and an outgoing transaction.
const state: SyncTransactionState = {
queued: [],
applied: [
{
transactionId: 'txn9',
actions: [
{
type: 'document.edit',
documentId: 'doc1',
documentType: 'book',
patches: [{set: {foo: 'reverted'}}],
},
],
disableBatching: false,
outgoingActions: [],
outgoingMutations: [],
base: {[draftId]: {...exampleDoc, _id: draftId, foo: 'old', _rev: 'rev1'}},
working: {[draftId]: {...exampleDoc, _id: draftId, foo: 'changed', _rev: 'rev2'}},
previous: {[draftId]: {...exampleDoc, _id: draftId, foo: 'old', _rev: 'rev1'}},
previousRevs: {[draftId]: 'rev1'},
timestamp: '2025-02-06T00:04:00.000Z',
},
],
outgoing: {
transactionId: 'txnOut',
actions: [
{
type: 'document.edit',
documentId: 'doc1',
documentType: 'book',
patches: [{set: {foo: 'changed'}}],
},
],
disableBatching: false,
batchedTransactionIds: ['txnOut'],
outgoingActions: [],
outgoingMutations: [],
base: {},
working: {},
previous: {},
previousRevs: {},
timestamp: '2025-02-06T00:04:30.000Z',
},
grants,
documentStates: {
[draftId]: {
id: draftId,
subscriptions: ['sub1'],
local: {...exampleDoc, _id: draftId, foo: 'changed', _rev: 'rev2'},
remote: {...exampleDoc, _id: draftId, foo: 'old', _rev: 'rev1'},
unverifiedRevisions: {
txnOut: {
transactionId: 'txnOut',
documentId: draftId,
previousRev: 'rev1',
timestamp: '2025-02-06T00:04:30.000Z',
},
},
},
[pubId]: {
id: pubId,
subscriptions: ['sub1'],
local: {...exampleDoc, _id: pubId, foo: 'pub', _rev: 'revPub'},
},
},
}
const newState = revertOutgoingTransaction(state)
expect(newState.outgoing).toBeUndefined()
const docState = newState.documentStates[draftId]
expect(docState?.unverifiedRevisions && docState.unverifiedRevisions['txnOut']).toBeUndefined()
})
})
describe('applyRemoteDocument', () => {
it('does nothing if there is no document state for the given documentId', () => {
const state: SyncTransactionState = {
queued: [],
applied: [],
outgoing: undefined,
grants,
documentStates: {},
}
const remote: RemoteDocument = {
type: 'sync',
documentId: 'docX',
document: {...exampleDoc, _id: 'docX', foo: 'remote'},
revision: 'revRemote',
timestamp: '2025-02-06T00:05:00.000Z',
}
const events = new Subject<DocumentEvent>()
const newState = applyRemoteDocument(state, remote, events)
expect(newState).toEqual(state)
})
it('verifies an unverified revision when the revision matches and previousRev is as expected', () => {
const docId = getDraftId('doc1')
const initialState: SyncTransactionState = {
queued: [],
applied: [],
outgoing: undefined,
grants,
documentStates: {
[docId]: {
id: docId,
subscriptions: ['sub1'],
local: {...exampleDoc, _id: docId, foo: 'old'},
remote: {...exampleDoc, _id: docId, foo: 'old'},
unverifiedRevisions: {
txn10: {
transactionId: 'txn10',
documentId: docId,
previousRev: 'revOld',
timestamp: '2025-02-06T00:06:00.000Z',
},
},
},
},
}
const remote: RemoteDocument = {
type: 'sync',
documentId: docId,
document: {...exampleDoc, _id: docId, foo: 'new'},
revision: 'txn10',
previousRev: 'revOld',
timestamp: '2025-02-06T00:07:00.000Z',
}
const events = new Subject<DocumentEvent>()
const newState = applyRemoteDocument(initialState, remote, events)
const newDocState = newState.documentStates[docId]
expect(newDocState?.remote).toEqual(remote.document)
expect(newDocState?.remoteRev).toEqual(remote.revision)
// The matching unverified revision should be removed.
expect(newDocState?.unverifiedRevisions?.['txn10']).toBeUndefined()
})
it('rebases local changes when no matching unverified revision is found', () => {
// In this branch we simply let processActions rebase so that the local becomes the remote.
const docId = getDraftId('doc1')
const initialState: SyncTransactionState = {
queued: [],
applied: [],
outgoing: undefined,
grants,
documentStates: {
[docId]: {
id: docId,
subscriptions: ['sub1'],
local: {...exampleDoc, _id: docId, foo: 'local'},
remote: {...exampleDoc, _id: docId, foo: 'old'},
unverifiedRevisions: {},
},
},
}
const remote: RemoteDocument = {
type: 'sync',
documentId: docId,
document: {...exampleDoc, _id: docId, foo: 'remote'},
revision: 'txn11',
previousRev: 'revMismatch',
timestamp: '2025-02-06T00:08:00.000Z',
}
const events = new Subject<DocumentEvent>()
const newState = applyRemoteDocument(initialState, remote, events)
const newDocState = newState.documentStates[docId]
expect(newDocState?.remote).toEqual(remote.document)
expect(newDocState?.remoteRev).toEqual(remote.revision)
// For this simple test we expect that the local is “rebased” to the remote document.
expect(newDocState?.local).toEqual(remote.document)
})
// Test that a sync event removes outdated unverified revisions.
it('removes outdated unverified revisions when a sync event is received', () => {
const docId = getDraftId('doc1')
// An unverified revision created at an earlier time.
const outdatedTimestamp = new Date('2025-02-06T00:09:00.000Z').toISOString()
// The incoming sync event timestamp is later.
const syncTimestamp = new Date('2025-02-06T00:10:00.000Z').toISOString()
const futureTimestamp = new Date('2025-02-06T00:11:00.000Z').toISOString()
const initialState: SyncTransactionState = {
queued: [],
applied: [],
outgoing: undefined,
grants,
documentStates: {
[docId]: {
id: docId,
subscriptions: ['sub-sync'],
local: {...exampleDoc, _id: docId, foo: 'local'},
remote: {...exampleDoc, _id: docId, foo: 'old'},
// The unverified revisions from previous transactions.
unverifiedRevisions: {
txn12: {
transactionId: 'txn12',
documentId: docId,
previousRev: 'revOld',
timestamp: outdatedTimestamp,
},
txn13: {
transactionId: 'txn13',
documentId: docId,
previousRev: 'anotherRev',
timestamp: futureTimestamp,
},
},
},
},
}
const remote: RemoteDocument = {
type: 'sync',
documentId: docId,
document: {...exampleDoc, _id: docId, foo: 'remote-sync'},
revision: 'currentRev',
timestamp: syncTimestamp,
previousRev: undefined,
}
// Create a dummy events subject.
const events = new Subject<DocumentEvent>()
const newState = applyRemoteDocument(initialState, remote, events)
const newDocState = newState.documentStates[docId]
// Expect that all outdated unverified revisions are removed.
expect(newDocState?.unverifiedRevisions).toEqual({
txn13: {
documentId: docId,
previousRev: 'anotherRev',
timestamp: futureTimestamp,
transactionId: 'txn13',
},
})
expect(newDocState?.remote).toEqual(remote.document)
expect(newDocState?.remoteRev).toBe('currentRev')
})
})
describe('addSubscriptionIdToDocument', () => {
it('adds a subscription id to an existing document state', () => {
const docId = 'doc2'
const initialState: SyncTransactionState = {
queued: [],
applied: [],
outgoing: undefined,
grants,
documentStates: {
[docId]: {...exampleDoc, id: docId, subscriptions: ['subA']},
},
}
const newState = addSubscriptionIdToDocument(initialState, docId, 'subB')
expect(newState.documentStates[docId]?.subscriptions).toContain('subA')
expect(newState.documentStates[docId]?.subscriptions).toContain('subB')
})
it('creates a new document state if one does not exist', () => {
const docId = 'doc3'
const initialState: SyncTransactionState = {
queued: [],
applied: [],
outgoing: undefined,
grants,
documentStates: {},
}
const newState = addSubscriptionIdToDocument(initialState, docId, 'subC')
expect(newState.documentStates[docId]).toBeDefined()
expect(newState.documentStates[docId]?.subscriptions).toContain('subC')
})
})
describe('removeSubscriptionIdFromDocument', () => {
it('removes a subscription id but leaves the document state if other subscriptions remain', () => {
const docId = 'doc4'
const initialState: SyncTransactionState = {
queued: [],
applied: [],
outgoing: undefined,
grants,
documentStates: {
[docId]: {id: docId, subscriptions: ['subD', 'subE']},
},
}
const newState = removeSubscriptionIdFromDocument(initialState, docId, 'subD')
expect(newState.documentStates[docId]?.subscriptions).not.toContain('subD')
expect(newState.documentStates[docId]?.subscriptions).toContain('subE')
})
it('removes the document state entirely if no subscriptions remain', () => {
const docId = 'doc5'
const initialState: SyncTransactionState = {
queued: [],
applied: [],
outgoing: undefined,
grants,
documentStates: {
[docId]: {id: docId, subscriptions: ['subF']},
},
}
const newState = removeSubscriptionIdFromDocument(initialState, docId, 'subF')
expect(newState.documentStates[docId]).toBeUndefined()
})
it('returns the same state if the document state does not exist', () => {
const initialState: SyncTransactionState = {
queued: [],
applied: [],
outgoing: undefined,
grants,
documentStates: {},
}
const newState = removeSubscriptionIdFromDocument(initialState, 'nonexistent', 'subX')
expect(newState).toEqual(initialState)
})
})