import { FirestorePluginOptions } from '../CreatePlugin' ; import { Countdown , CountdownInstance } from './Countdown' ; import { SyncBatch } from '@magnetarjs/core' ; import type { WriteBatch } from 'firebase/firestore' ; import { doc , writeBatch as createWriteBatch , deleteField } from 'firebase/firestore' ; import { isEmptyObject , isNumber } from 'is-what' ; import { merge as mergeObjects } from 'merge-anything' ; import { removeProp } from 'remove-anything' ; const MAX_OPERATION_COUNT = 500 ; type Stack = { /**
     * Maximum 500! If < 500 additional operations can be added to this same stack.
     */ operationCount : number ; resolves : ( ( ) => void ) [ ] ; rejects : ( ( error : any ) => void ) [ ] ; batch : SyncBatch ; } ; export type BatchSync = { assign : ( documentPath : string , payload : Record < string , any > , debounceMsOverwrite ? : number ) => Promise < SyncBatch > ; merge : ( documentPath : string , payload : Record < string , any > , debounceMsOverwrite ? : number ) => Promise < SyncBatch > ; replace : ( documentPath : string , payload : Record < string , any > , debounceMsOverwrite ? : number ) => Promise < SyncBatch > ; insert : ( documentPath : string , payload : Record < string , any > , debounceMsOverwrite ? : number ) => Promise < SyncBatch > ; deleteProp : ( documentPath : string , payload : string [ ] , debounceMsOverwrite ? : number ) => Promise < SyncBatch > ; delete : ( documentPath : string , debounceMsOverwrite ? : number ) => Promise < SyncBatch > ; forceSyncEarly : ( ) => Promise < void > ; } ; const newStack = ( ) : Stack => ( { operationCount : 0 , resolves : [ ] , rejects : [ ] , batch : { insert : new Map ( ) , assign : new Map ( ) , merge : new Map ( ) , replace : new Map ( ) , deleteProp : new Map ( ) , delete : new Set ( ) } , } ) ; param { Record < string , any > } payload returns { number } function countOperations ( payload ) { const count = 1 ; return count ; } function preparePayload ( _payload ) { const payload = _payload ; const operationCount = countOperations ( _payload ) ; return { payload , operationCount } ; } function prepareReturnPromise ( stack ) { return new Promise ( ( resolve , reject ) => { stack . resolves . push ( ( ) => resolve ( stack . batch ) ) ; stack . rejects . push ( reject ) ; } ) ; } export returns { BatchSync } export function batchSyncFactory ( firestorePluginOptions ) { const { db , syncDebounceMs , debug } = firestorePluginOptions ; /**
     * A function that applies everything in the `SyncBatch` to a Firestore's `WriteBatch`.
     * It mutates the passed `batch`.
     */ function applySyncBatch ( writeBatch , batch ) { batch . insert . forEach ( ( payload , documentPath ) => { const ref = doc ( db , documentPath ) ; writeBatch . set ( ref , payload ) ; } ) ; batch . assign . forEach ( ( payload , documentPath ) => { const ref = doc ( db , documentPath ) ; writeBatch . set ( ref , payload , { mergeFields : Object . keys ( payload ) } ) ; } ) ; batch . merge . forEach ( ( payload , documentPath ) => { const ref = doc ( db , documentPath ) ; writeBatch . set ( ref , payload , { merge : true } ) ; } ) ; batch . replace . forEach ( ( payload , documentPath ) => { const ref = doc ( db , documentPath ) ; writeBatch . set ( ref , payload ) ; } ) ; batch . deleteProp . forEach ( ( payload , documentPath ) => { const ref = doc ( db , documentPath ) ; const _payload = [ ... payload ] . reduce ( ( carry , propPath ) => ( { ... carry , [ propPath ] : deleteField ( ) , } ) , { } as any ) ; writeBatch . update ( ref , _payload ) ; } ) ; batch . delete . forEach ( ( documentPath ) => { const ref = doc ( db , documentPath ) ; writeBatch . delete ( ref ) ; } ) ; } const state = { queue : [ ] , countdown : null , } ; async function prepareStack ( operationCount , queueIndex = 0 ) { if ( ! state . queue [ queueIndex ] ) state . queue [ queueIndex ] = newStack ( ) ; const stack = state . queue [ queueIndex ] ; if ( stack . operationCount + operationCount >= MAX_OPERATION_COUNT ) { return prepareStack ( operationCount , queueIndex + 1 ) ; } stack . operationCount += operationCount ; return stack ; } /**
     * Removes one `stack` entry from the `queue` & executes batch.commit() and makes sure to reject or resolve all actions when this promise is resolved
     */ function executeSync ( ) { state . countdown = null ; const stack = state . queue . shift ( ) ; if ( ! stack ) { throw new Error ( 'executeSync executed before it was instantiated' ) ; } const writeBatch = createWriteBatch ( db ) ; try { applySyncBatch ( writeBatch , stack . batch ) ; } catch ( error ) { stack . rejects . forEach ( ( rej ) => rej ( error ) ) ; if ( state . queue . length ) { triggerSync ( 0 ) ; } return ; } if ( debug ) { console . log ( '[magnetar] Syncing to firestore...' , stack . batch ) ; } writeBatch . commit ( ) . then ( ( ) => stack . resolves . forEach ( ( res ) => res ( ) ) ) . catch ( ( error ) => stack . rejects . forEach ( ( rej ) => rej ( error ) ) ) . finally ( ( ) => { if ( state . queue . length ) { triggerSync ( 0 ) ; } } ) ; } function forceSyncEarly ( ) { return new Promise ( ( resolve ) => { if ( ! state . countdown ) return resolve ( ) ; state . countdown . done . then ( ( ) => resolve ( ) ) ; state . countdown . forceFinish ( ) ; } ) ; } param { number } debounceMsOverwrite returns { CountdownInstance } function prepareCountdown ( debounceMsOverwrite ? ) { if ( ! state . countdown ) { const ms = isNumber ( debounceMsOverwrite ) ? debounceMsOverwrite : syncDebounceMs ; state . countdown = Countdown ( ms ) ; state . countdown . done . then ( ( ) => executeSync ( ) ) ; } return state . countdown ; } function triggerSync ( debounceMsOverwrite ? ) { const countdown = prepareCountdown ( debounceMsOverwrite ) ; countdown . restart ( debounceMsOverwrite ) ; } async function insert ( documentPath , _payload , debounceMsOverwrite ? ) { const { payload , operationCount } = preparePayload ( _payload ) ; const stack = await prepareStack ( operationCount ) ; stack . batch . insert . set ( documentPath , payload ) ; const promise = prepareReturnPromise ( stack ) ; triggerSync ( debounceMsOverwrite ) ; return promise ; } async function assign ( documentPath , _payload , debounceMsOverwrite ? ) { const { payload , operationCount } = preparePayload ( _payload ) ; let stack = await prepareStack ( operationCount ) ; if ( stack . batch . merge . has ( documentPath ) || stack . batch . deleteProp . has ( documentPath ) ) { await forceSyncEarly ( ) ; stack = await prepareStack ( operationCount ) ; } const replacePayload = stack . batch . replace . get ( documentPath ) ; if ( replacePayload ) { stack . batch . replace . set ( documentPath , { ... replacePayload , ... payload } ) ; } if ( ! replacePayload ) { const previousPayload = stack . batch . assign . get ( documentPath ) ; stack . batch . assign . set ( documentPath , { ... previousPayload , ... payload } ) ; } const promise = prepareReturnPromise ( stack ) ; triggerSync ( debounceMsOverwrite ) ; return promise ; } async function merge ( documentPath , _payload , debounceMsOverwrite ? ) { const { payload , operationCount } = preparePayload ( _payload ) ; let stack = await prepareStack ( operationCount ) ; const payloadSafe = removeProp ( payload , { } ) ; if ( isEmptyObject ( payloadSafe ) ) { const promise = prepareReturnPromise ( stack ) ; triggerSync ( debounceMsOverwrite ) ; return promise ; } if ( stack . batch . assign . has ( documentPath ) || stack . batch . deleteProp . has ( documentPath ) ) { await forceSyncEarly ( ) ; stack = await prepareStack ( operationCount ) ; } const replacePayload = stack . batch . replace . get ( documentPath ) ; if ( replacePayload ) { stack . batch . replace . set ( documentPath , mergeObjects ( replacePayload , payloadSafe ) ) ; } if ( ! replacePayload ) { const previousPayload = stack . batch . merge . get ( documentPath ) || { } ; stack . batch . merge . set ( documentPath , mergeObjects ( previousPayload , payloadSafe ) ) ; } const promise = prepareReturnPromise ( stack ) ; triggerSync ( debounceMsOverwrite ) ; return promise ; } async function replace ( documentPath , _payload , debounceMsOverwrite ? ) { const { payload , operationCount } = preparePayload ( _payload ) ; let stack = await prepareStack ( operationCount ) ; if ( stack . batch . deleteProp . has ( documentPath ) ) { await forceSyncEarly ( ) ; stack = await prepareStack ( operationCount ) ; } stack . batch . replace . set ( documentPath , payload ) ; const promise = prepareReturnPromise ( stack ) ; triggerSync ( debounceMsOverwrite ) ; return promise ; } async function deleteProp ( documentPath , propPaths , debounceMsOverwrite ? ) { let operationCount = 0 ; const stack = await prepareStack ( operationCount ) ; const map = stack . batch . deleteProp ; if ( ! map . has ( documentPath ) ) { map . set ( documentPath , new Set ( ) ) ; operationCount = 1 ; } const set = map . get ( documentPath ) as Set < string > ; propPaths . forEach ( ( p ) => set . add ( p ) ) ; const promise = prepareReturnPromise ( stack ) ; triggerSync ( debounceMsOverwrite ) ; return promise ; } async function _delete ( documentPath , debounceMsOverwrite ? ) { const operationCount = 0 ; const stack = await prepareStack ( operationCount ) ; const promise = prepareReturnPromise ( stack ) ; stack . batch . delete . add ( documentPath ) ; triggerSync ( debounceMsOverwrite ) ; return promise ; } return { assign , merge , replace , insert , deleteProp , delete : _delete , forceSyncEarly } ; }