//@ts-check
/**
* Data Manager class
*
* Forms the glue that connects the view to the model and server resource.
*
* Data Manager class is responsible for loading data, allowing model transformations into appropriate view model,
* and allowing for the templating of loaded or uploaded data automatically into the views
*
* Function is fine grained, using scopes
* A view can hook itself to a data cycle, getting notifications of updates and responding appropriately, through its view manager
*
* The data manager is lifecycle aware, so it will not update views that have been destroyed. HOWEVER, this
* lifecycle is in the general context of the fragment or view panel. If you remove a view, it's your responsibility
* to avoid making any changes to it while its reference is null, if you're extending some self-managed listeners or timed events
*
* Main lifecycle object controls data requests which are also lifecycle aware. i.e. if host is destroyed,
* data updates will be deferred.
*
* So, what of stuff you want to run on the background, always? Attach the data manager to an AppService (not an AppWindowService which is AppWindow dependent) - @todo more on these later
* and it will always run and update views based on registered hooks, which are automatically deregistered on lifecycle changes
* of the view's host, observed via the lifecycle object.
*
* YOU CAN HAVE MULTIPLE DATA MANAGERS TO ALLOW FOR MORE COMPLEX DATA HANDLING AND VIEW MANIPULATION
*
* You can also possibly pass a data manager to another function or component that wants to temporarily manipulate some data
* HOWEVER, ensure you passed the model ID because that external code will not be able to reference the model correctly in updates
*
*
*/
import GenericBuildPipelineWorker from "../utils/generic-pipeline-worker/generic_build_pipeline_worker";
import ExclusiveStack from "../utils/abstract-data-types/exclusive-stack/exclusive_stack_adt";
import Queue from "../utils/abstract-data-types/queue/queue";
import Stack from "../utils/abstract-data-types/stack/stack_adt";
import RandomNumberCharGenUtils from "../utils/random-number-generator/random_number_char_generator";
import LifecycleRemoteRequestUtils from "../utils/remote-requests/lifecycle/lifecycle_remote_request_utils";
import ListReverser from "../utils/lists/list_reverser";
import DeleteDataPipelineWorker from "./pipeline-workers/delete/delete_data_pipeline_worker";
import LoadNewDataPipelineWorker from "./pipeline-workers/load/load_new_data_pipeline_worker";
import LoadServerSidePipelineWorker from "./pipeline-workers/server-side/load_server_side_pipeline_worker";
import UpdateDataPipelineWorker from "./pipeline-workers/update/update_data_pipeline_worker";
import UploadDataPipelineWorker from "./pipeline-workers/upload/upload_data_pipeline_worker";
/**
* SOLVED A BIG PROBLEM I HAD HERE. DON'T PROVIDE A GENERIC TYPE. WILL ALLOW OTHERS TO OVERRIDE WITHOUT ISSUES
* So, leave as <>.
*
* Correct in generic build pipeline worker
*
* Or, pass any (*). BETTER. Allows generic templating in jsdocs.
* @template {{}} M
*/
class DataManager{
/**
* @template E
* @param {DataManagerConstructorArgs<M, E>} args
*/
constructor(args){
this.primaryLifecycleObject = args.primaryLifeCycleObject;
//First binds itself to the main lifecycle object
this.bindLifecycleObject();
this.masterAPIOptions = args.masterAPIOptions;
this.maintainNetworkOnFlushAll_Global = args.flushAllNetworkPolicy?.dontAbortGlobal;
this.maintainNetworkOnFlushAll_Specific = args.flushAllNetworkPolicy?.dontAbortMutationSpecific;
this.dataRecordsStamp = null;
this.generateDataRecordsStamp();
/**
* @type {import("DataManager").DataManagerWorkingModel<M>}
*/
this.masterWorkingModel = {
masterModels: [],
scopedOptions: {
apis: {},
views: {}
},
dataWatchers: {}
}
/***
* AUTO-GENERATE?
* @type {DataManagerInstance<M>['id']}
*/
this.id = args.id ? args.id : RandomNumberCharGenUtils.generateRandomNumChar(9);
/**
* Working pipelines
* Managed by main lifecycle object provided in constructor
*/
/**
* @deprecated - new hydration logic
* @type {import("./pipeline-workers/server-side/load_server_side_pipeline_worker.d.ts").LoadServerSidePipelineWorkerInstance<import("./pipeline-workers/server-side/load_server_side_pipeline_worker.d.ts").LoadServerSideDataPipelineStates, import("./pipeline-workers/server-side/load_server_side_pipeline_worker").LoadServerSideBuildArgs<M>, import("./pipeline-workers/server-side/load_server_side_pipeline_worker.d.ts").LoadServerSidePipelineDFAGroups, null>}
*/
this.serverSideDataLoadPipeline = null;
/**
* @type {UploadDataPipelineWorker<M>}
*/
this.uploadDataPipeline = new UploadDataPipelineWorker({dataManager: this});
/**
* @type {LoadNewDataPipelineWorker<M>}
*/
this.loadNewDataPipeline = new LoadNewDataPipelineWorker({dataManager: this});
/**
* @type {UpdateDataPipelineWorker<M>}
*/
this.updateDataPipeline = new UpdateDataPipelineWorker({dataManager: this});
/**
* @type {DeleteDataPipelineWorker<M>}
*/
this.deleteDataPipeline = new DeleteDataPipelineWorker({dataManager: this});
/**
* Using stack so later can debug order of requests
* @type { Map<string, Stack<import("DataManager").DataOperationsInfo<M>>> }
*/
this.dataOperationsRecords = new Map();
/**
* @type {import("DataManager").DataOperationsOverride}
*/
this.dataOperationsOverrideBehavior = args.dataOperationsOverrideBehavior ? args.dataOperationsOverrideBehavior : "wait";
this.dataManagerInit = false;
/**
* @type {QueueInstance<{manager: StandardViewManagerInstance<M, *>, initArgs: import("StandardViewManager").StandardViewManagerInitArgs<M, *>}>}
*/
this.viewManagersPendingInit = new Queue();
/**
* @type {DataManagerConstructorArgs<M, E>['serverSide']}
*/
this.serverSideOptions = args.serverSide;
if(this.serverSideOptions?.hydrateFromServerSide){
this.initDataManagerServerSide()
}
}
generateDataRecordsStamp(){
this.dataRecordsStamp = RandomNumberCharGenUtils.generateRandomNumChar(12)
}
/**
* Called to initialize server-side data for the data manager
* @type {DataManagerInstance<M>['initDataManagerServerSide']}
*/
async initDataManagerServerSide(){
/**
* DATA MANAGER HYDRATION - WORKS LIKE THIS
*/
/**
* Use clientJavaScriptHydrator
*
* This is a script added by the server that you can invoke to hydrate your data model as needed
*
* The script exposes an object DataManagerHydrator, which contains unique members with data (array<Partial<model>>) that is used to hydrate your
* data manager
*
* Now, this MUST conform to Partial<model>, model being the data type actually used by your data manager, but, you can hide crucial information by just providing say,
* a public data ID as the only server-side hydrated data
*
* How?
*
* Data manager will run a hydration cycle.
*
* First, it will read the raw data and use that as standOn data from the server
* Then, if you need to (pass a flag: finalNetworkHydration: true), it will call a couple of
* network hydration calls, using scope "MODEL_ROOT", but skipping viewManagerHooks.
*
* You should provide an override networkInterface for the network operation
*
* Once the two cycles are complete, any view manager attached will be told of a possible
* serverSide render during it's init. This way, it will look to see if the views of its class
* are already attached, and spawn the necessary additionals correctly, so it can continue
* to work correctly (as many view managers per scope. However, ensure you have unique controlling per class or id [for standard])
*
* The latter ensures we don't worry about attachment order or the details of the view manager's view operations.
* It just carries out the init in its known way
*
* FORMAT FOR HYDRATOR
* <script id="data-manager-hydrator">
* const DataManagerHydrationInfo = {
*
* "info_name": {
* info: "info_array",
* extras: "extra_info" //(e.g, pagination end or markers). Also, seemingly non-json using my server-side hydration algo
* }
* }
* window.DataManagerHydrationInfo = DataManagerHydrationInfo;
* </script>
*
* Data manager deletes the hydration script immediately it reads it, if you set it so.
* Then, goes through the network cycle
* if needed, and proceeds with the rest of the init
*
* Add a completeCb which we can use to read what came from the server as well and set values
* such as telling the view manager's paginator that pagination is complete, when setting itself up (done with a different flag though)
*
* initDataManagerServerSide being used in ONE place only. So, override
*/
//read the hydration script
const hydrationScript = document.head.querySelector(`#${this.serverSideOptions.overrideScriptId || DataManager._HYDRATOR_SCRIPT_ID}`);
if(hydrationScript){
//get the info under key
/**
* @type {DataManagerHydrationInfo}
*/
const hydrationInfo = window.DataManagerHydrationInfo;
const fullInfoForKey = hydrationInfo[this.serverSideOptions.dataKey];
if(fullInfoForKey){
try{
/**
* @type {Partial<M>[]}
*/
let info = fullInfoForKey.info;
if(!Array.isArray(info)){
throw new Error("Your hydrated data should be an array of model type");
}
if(this.serverSideOptions.canHydrateData(info)){
//Now, do preProcessing, if necessary
if(this.serverSideOptions.preProcessHydrationInfo){
info = this.serverSideOptions.preProcessHydrationInfo(info);
}
/**
* @type {string[]}
*/
const modelIds = [];
//Now, run through this array. Create and commit models based on these values
info.forEach((model) => {
modelIds.push(this.createAndCommitModel(model, { skipViewHooks: true }));
});
//now, callback that we're done with this
const extras = fullInfoForKey.extras;
//Can use extras to, say, tell paginator if end of list
this.serverSideOptions.onHydrateComplete(info, extras);
//Now, process network, if provided - CONTINUE FROM HERE
if(this.serverSideOptions.networkOptions.runNetworkStep){
//run the network calls
//They don't provide a mapped data id. Therefore, do not update anything on the ui
//So, ensure first hydration includes ui-necessary data
modelIds.forEach((id) => {
this.updateModel("MODEL_ROOT", id, null, null, { skipViewHooks: true }, this.serverSideOptions.networkOptions.overrideNetworkInterface);
});
}
//AND, WE ARE DONE
//Remove this record - so, in accessible, if developer wants
if(this.serverSideOptions.deleteScriptOnComplete){
document.head.removeChild(hydrationScript);
}
} else {
console.error("You rejected this server side hydration data being parsed");
}
} catch(err){
console.error(err);
console.error(`Ran into an issue parsing info for data key: ${this.serverSideOptions.dataKey}`);
console.log("Confirm the structure is okay.");
}
} else {
console.error(`Failed to read full info from hydration script for data key: ${this.serverSideOptions.dataKey}`)
console.warn(`Confirm info was provided and already processed server-side data for the data manager ${this.id}`);
}
} else {
console.error("DATA MANAGER: Initializing server-side data but no hydration script provided");
}
}
bindLifecycleObject(){
//Cancels all calls if destroyed. Otherwise, runs
//FOR VIEWS, will have "destroyNetworkPolicy. Add later"
//No longer needed tbh. i.e for cancelling remote requests. Just cancels pipelines. YES.
//TRIGGER ALL PIPELINES TO CANCEL
//Server load in pipeline as well now
this.primaryLifecycleObject.registerLifeCycleListeners({
onFragmentRunning: () => { /* Redundant. Fragment will be running when this happens */ },
onFragmentCancelled: () => { /* Also redundant */ },
onFragmentDestroyed: () => {
//DO NOT CLEAR CURRENT MODELS LIST
//Allow asynchronous mutations permitted to run network only to run and complete (especially getting oldModel)
//Then, all resources freed
this.cancelAllDataOperations(true);
}
});
}
cancelAllDataOperations(overrideBuildOnlyOnDestroy){
if(this.serverSideDataLoadPipeline){
this.serverSideDataLoadPipeline.cancelServerSideLoad({ apiOptions: this.masterAPIOptions });
this.serverSideDataLoadPipeline = null;
} else {
//Cancel all other pipelines
//Doing this cause serverSideDataLoad pipeline mutually excludes the rest
//Trigger pipelines based on running activities or buildIds.
//Upload
if(this.uploadDataPipeline){
this.uploadDataPipeline.cancelDataUpload(null, null, true, overrideBuildOnlyOnDestroy ? overrideBuildOnlyOnDestroy : this.maintainNetworkOnFlushAll_Global ? this.maintainNetworkOnFlushAll_Global : this.maintainNetworkOnFlushAll_Specific?.upload);
}
//Load new
if(this.loadNewDataPipeline){
this.loadNewDataPipeline.cancelNewDataLoad(null, null, true, overrideBuildOnlyOnDestroy);
}
//Update
if(this.updateDataPipeline){
this.updateDataPipeline.cancelDataUpdate(null, null, true, overrideBuildOnlyOnDestroy ? overrideBuildOnlyOnDestroy : this.maintainNetworkOnFlushAll_Global ? this.maintainNetworkOnFlushAll_Global : this.maintainNetworkOnFlushAll_Specific?.update);
}
//Delete
if(this.deleteDataPipeline){
this.deleteDataPipeline.cancelDataDelete(null, null, true, overrideBuildOnlyOnDestroy ? overrideBuildOnlyOnDestroy : this.maintainNetworkOnFlushAll_Global ? this.maintainNetworkOnFlushAll_Global : this.maintainNetworkOnFlushAll_Specific?.delete);
}
}
//data records stamp should NOT be DataManager._RECORDS_AFTER_LIFECYCLE_DETACH_OR_FLUSH_ALL before this operation
//Otherwise, expect errors
const clearDataOperationsRecords = () => {
if(!this.getValidDataOperationsStack(this.dataRecordsStamp).isEmpty()){
//Merge any existing in this stamp to flushAll or lifecycle entries
this.getValidDataOperationsStack(DataManager._RECORDS_AFTER_LIFECYCLE_DETACH_OR_FLUSH_ALL).mergeWith(this.getValidDataOperationsStack(this.dataRecordsStamp));
}
//Delete the entry to this stamp completely
this.dataOperationsRecords.delete(this.dataRecordsStamp);
//By default, set stamp to this value. Flush all will generate a new one
this.dataRecordsStamp = DataManager._RECORDS_AFTER_LIFECYCLE_DETACH_OR_FLUSH_ALL;
}
clearDataOperationsRecords();
}
/**
* @deprecated
* PUT IN PIPELINE THAT CAN BE CANCELLED
* @returns {Promise<void>}
*/
async loadServerSideData(){
return new Promise((resolve, reject) => {
if(this.masterAPIOptions.serverSide && this.masterAPIOptions.serverSide.buildFromServerSide){
this.serverSideDataLoadPipeline = new LoadServerSidePipelineWorker(null);
this.serverSideDataLoadPipeline.initServerSideLoad({
apiOptions: this.masterAPIOptions,
setScopedAPIOption: (scope, paginationOptions) => {
this.masterWorkingModel.scopedOptions.apis[scope] = {
...this.masterWorkingModel.scopedOptions.apis[scope],
pagination: paginationOptions
}
},
createModelsCb: (models) => {
const newModelIDs = this.bulkCreateModels(models, { skipViewHooks: true });
/**
* @type {import("DataManager").CommitBulkModelOptions<M>[]}
*/
const commitOptions = [];
newModelIDs.forEach((id) => {
commitOptions.push({
modelID: id,
scope: "MODEL_ROOT",
orderedArrayIndices: null
});
});
this.commitBulkModels(commitOptions);
},
mainCb: () => {
//No longer needed
this.serverSideDataLoadPipeline = null;
resolve();
}
});
} else {
resolve();
}
});
}
/**
* @deprecated
*/
initViewManagersInWait(){
if(this.viewManagersPendingInit.length > 0){
this.viewManagersPendingInit.forEach((managerInfo) => {
managerInfo.manager.initViewManager(managerInfo.initArgs);
});
this.viewManagersPendingInit.clear();
}
}
static get _HYDRATOR_SCRIPT_ID(){
return "data-manager-hydrator";
}
/**
* @type {import("DataManager").DataManagerConstructor<*>['_SERVER_SIDE_PASSED']}
*/
static get _SERVER_SIDE_PASSED(){
return "SERVER_SIDE_PASSED";
}
/**
* @deprecated
*/
static get _SERVER_SIDE_DATA_ATTRS(){
return {
pagination: {
attrPaginationEnd: "pgn-e",
attrNextPageMarker: "pgn-c",
},
loadImmediatelyOverride: {
attrLoadMoreImmediatelyAfterServerSide: "load_more_immediately_after"
},
values: {
true: "1",
false: "0"
}
}
}
/**
* @type {import("DataManager").DataManagerConstructor<*>['_SCOPED_ARRAY_LITERAL']}
*/
static get _SCOPED_ARRAY_LITERAL(){
return "array";
}
/**
* @type {import("DataManager").DataManagerConstructor<*>['_ARRAY_SELF_TYPE']}
*/
static get _ARRAY_SELF_TYPE(){
return "selfType";
}
/**
* @type {import("DataManager").DataManagerConstructor<*>['_NESTED_SCOPE_KEY_SPLITTER']}
*/
static get _NESTED_SCOPE_KEY_SPLITTER(){
return ".";
}
/**
* @type {import("DataManager").DataManagerConstructor<*>['_MODEL_ROOT_SCOPE']}
*/
static get _MODEL_ROOT_SCOPE(){
return "MODEL_ROOT";
}
/**
* @type {import("DataManager").DataManagerConstructor<*>['_CANCELLED_DATA_OP']}
*/
static get _CANCELLED_DATA_OP(){
return "Cancelled";
}
static get _RECORDS_AFTER_LIFECYCLE_DETACH_OR_FLUSH_ALL(){
return "_LIFECYCLE_DETACHED_OR_FLUSH_ALL_RECORDS";
}
/**
* For data uploads
*
* @type {DataManagerInstance<M>['setDataLoadAPI']}
*/
setDataUploadAPI(addr, scope){
this.masterWorkingModel.scopedOptions.apis[scope].upload = addr;
}
/**
* For data loads
*
* @type {DataManagerInstance<M>['setDataLoadAPI']}
*/
setDataLoadAPI(addr, scope){
this.masterWorkingModel.scopedOptions.apis[scope].loadNew = addr;
}
/**
* For data updates
*
* @type {DataManagerInstance<M>['setDataUpdateAPI']}
*/
setDataUpdateAPI(addr, scope){
this.masterWorkingModel.scopedOptions.apis[scope].update = addr;
}
/**
* For data deletes
*
* @type {DataManagerInstance<M>['setDataDeleteAPI']}
*/
setDataDeleteAPI(addr, scope){
this.masterWorkingModel.scopedOptions.apis[scope].delete = addr;
}
/**
*
* @type {DataManagerInstance<M>['setDataWatcher']}
*/
setDataWatcher(scope, viewManager){
viewManager.getLifeCycleInstance().registerLifeCycleListeners({
onFragmentCancelled: () => {},
onFragmentRunning: () => {},
onFragmentDestroyed: () => {
if(this.masterWorkingModel.dataWatchers[scope]){
this.masterWorkingModel.dataWatchers[scope].viewManagers.splice(
this.masterWorkingModel.dataWatchers[scope].viewManagers.findIndex((manager) => manager.id === viewManager.id),
1
);
}
}
});
this.masterWorkingModel.dataWatchers[scope] = {
viewManagers: this.masterWorkingModel.dataWatchers[scope] && this.masterWorkingModel.dataWatchers[scope].viewManagers ? this.masterWorkingModel.dataWatchers[scope].viewManagers.concat([viewManager]) : [viewManager]
}
}
/**
* Uploading data NOT in model using the upload data API address - READ MORE
*
* Update spec to loadData, such that for scope lower than MODEL_ROOT, we don't create a new model.
* instead, we'll just update the existing
*
* @type {DataManagerInstance<M>['uploadNewData']}
*
*/
uploadNewData(newData, options, scope, mappedDataId, overrideNetworkInterface, overrideUploadAddr, overrideNetworkInterfaceScope){
//Add data to model
let modelID = "";
if(scope === "MODEL_ROOT"){
modelID = this.createModel(newData, { skipViewHooks: true }); //Skipping to use normal flow
} else {
const partialModel = {};
this.spawnPartialShellModel(scope, newData, partialModel, mappedDataId); //partial model was literal {}
modelID = this.createModel(partialModel, { skipViewHooks: true });
}
const reqAddr = overrideUploadAddr ? overrideUploadAddr : this.masterWorkingModel.scopedOptions.apis[scope].upload;
return new Promise((resolve, reject) => {
this.requestDataUpload(modelID, options, scope, newData, mappedDataId, reqAddr, "uploadNew", overrideNetworkInterface, overrideNetworkInterfaceScope).then((success) => {
resolve(success);
}).catch((err) => {
//Delete from model. Direct cause viewManagers already told
this.deleteCompleteModel(modelID);
reject(err);
});
});
}
/**
*
* @type {DataManagerInstance<M>['uploadDataInModel']}
*/
uploadDataInModel(modelID, options, scope, newData, mappedDataId, overrideNetworkInterface, overrideUploadAddr, overrideNetworkInterfaceScope){
return this.requestDataUpload(modelID, options, scope, newData, mappedDataId, overrideUploadAddr ? overrideUploadAddr : this.masterWorkingModel.scopedOptions.apis[scope].upload, "upload", overrideNetworkInterface, overrideNetworkInterfaceScope);
}
/**
* @template {keyof DataManagerInstance<M>['masterWorkingModel']['scopedOptions']['apis']} ReqScope
* @param {string} modelID
* @param {SendDataOptions} options
* @param {ReqScope} scope
* @param {ValueTypeOfNested<M, ReqScope>} newData
* @param {string} mappedDataId
* @param {string} reqAddr
* @param {DataManagerMutations} requestedMutation
* @param {import("DataManager").DataOperationsNetworkInterface<ValueTypeOfNested<M, ReqScope>, ReqScope>} [overrideNetworkInterface]
* @param {NestedParentKeysOf<M, ReqScope>} [overrideNetworkInterfaceScope]
* @returns {Promise<import("DataManager").DataOperationMsg<ValueTypeOfNested<M, ReqScope>>>}
*/
requestDataUpload(modelID, options, scope, newData, mappedDataId, reqAddr, requestedMutation, overrideNetworkInterface, overrideNetworkInterfaceScope){
//Get the current stamp. Useful for checking if flushAll event has occured thus delayed start of mutation
//due to promise generation and execution cycle, should run in appropriate cancellation mode
const oldModel = this.getModel(modelID);
//Scope data correctly if override
newData = this.getNewDataScopedToRequest(overrideNetworkInterfaceScope, scope, newData, mappedDataId);
const finalScope = overrideNetworkInterfaceScope ? overrideNetworkInterfaceScope : scope;
const oldData = this.getScopedModel(finalScope, mappedDataId, modelID, false);
return this.runDataMutation(requestedMutation, scope, modelID, newData, options, mappedDataId, (operationStatus, runNonNetworkCancel, comparator) => {
//Do upload
return new Promise(async (resolve, reject) => {
if(operationStatus.operable){
this.uploadDataPipeline.uploadNewData({
//Provide oldCompleteModel here
oldCompleteModel: oldModel,
cancelBuildOnly_ByPassNonNetwork: runNonNetworkCancel,
dataMutation: requestedMutation,
apiOptions: this.masterAPIOptions,
scope: finalScope,
originalScope: scope,
mappedDataId: mappedDataId,
oldData: oldData,
newData: newData,
options: options,
dataMutationAPI: reqAddr,
_get_not_orderedViewManagers: () => { return comparator.ofViews() },
modelID_s: modelID,
/**
* @param {string} id Only expect one id here
*/
mutationStateUpdate: (mutationState, id, APIScope, model) => {
const targetModel = this.masterWorkingModel.masterModels.find((model) => model.modelID === id);
if(!targetModel){
console.error("CRITICAL DATA MANAGER ERROR: Target model not found while ongoing mutation")
return;
}
if(APIScope === "MODEL_ROOT"){
targetModel.data.temp.master.state = mutationState;
} else {
targetModel.data.temp.scoped[APIScope].state = mutationState;
}
if(mutationState === "onMutate"){
//Tell observers request has started
this.informObserversOfMutationState(scope, (observer) => {
observer.onReqStart(reqAddr, requestedMutation, scope);
});
} else if(mutationState === "onCommit"){
this.overwriteModel(id, model, APIScope);
this.commitModel(id, APIScope, this.getOrderedArrayIndicesForMappedDataId(APIScope, mappedDataId));
//Tell observers
this.informObserversOfMutationState(scope, (observer) => {
observer.onReqEnd(reqAddr, requestedMutation, scope);
});
} else if(mutationState === "complete"){
//Tell view manager watchers
//Doing this so that they're the last to get updates
if(this.masterWorkingModel.dataWatchers[APIScope]){
this.masterWorkingModel.dataWatchers[APIScope].viewManagers.forEach((manager) => {
manager.onExternalWatchCommit(requestedMutation, model, null);
});
}
} else if(mutationState === "onError"){
//Tell observers. They don't respect skipUIHooks? Yes.
this.informObserversOfMutationState(scope, (observer) => {
observer.onReqErr(reqAddr, requestedMutation, scope);
});
} else if(mutationState === "onCancel"){
//HANDLE CANCELLATIONS
//Tell observers
this.informObserversOfMutationState(scope, (observer) => {
observer.onReqCancel(reqAddr, requestedMutation, scope);
});
}
},
completeCb: (modelID, scope, finalModel, err, overrideScopeCommitModel) => {
if(!err){
//Update watchers. Makes sense here. Sure pipeline is done correctly
this.updateWatchers(requestedMutation, scope, finalModel, null);
//Resolve if no error
resolve({
committedModel: finalModel !== undefined ? structuredClone(finalModel) : null,
overrideScopeCommitModel: overrideScopeCommitModel !== undefined ? structuredClone(overrideScopeCommitModel) : null,
requestedMutation: requestedMutation,
previousMutation: operationStatus.previousMutation,
status: "invoked",
msg: "Mutation invoked and completed."
});
} else {
reject(err);
}
},
networkInterface: overrideNetworkInterface ? overrideNetworkInterface : this.masterWorkingModel.scopedOptions.apis[finalScope].networkInterface
});
} else {
//Failed. Tell of failure
reject({
requestedMutation: requestedMutation,
previousMutation: operationStatus.previousMutation,
status: "denied",
msg: operationStatus.msg//"Mutation denied. Waiting for ongoing mutation on this model to complete"
});
}
});
});
}
/**
* @type {DataManagerInstance<M>['loadData']}
*/
loadData(requestScope, scopedInfo, options, overrideNetworkInterface, overrideLoadNewAddr){
const scope = requestScope;
const currentRecordsStamp = this.dataRecordsStamp;
//mappedDataId being ignored cause scope of load will be to list, not list member (where mappedDataId counts)
/**
* @todo implement new mappedDataId spec to allow per list member asynchronous mutations for non MODEL_ROOT scopes
*/
const oldModel = scopedInfo?.modelID ? this.getModel(scopedInfo.modelID) : null;
return this.runDataMutation("loadNew", scope, scopedInfo ? scopedInfo.modelID : DataManager._MODEL_ROOT_SCOPE, null, options, null, (operationStatus, runNonNetworkCancel, comparator) => {
//Do the loading
return new Promise(async (resolve, reject) => {
if(operationStatus.operable){
this.loadNewDataPipeline.loadNewData({
scope: scope,
originalScope: scope,
oldData: null,
oldCompleteModel: oldModel,
modelID_s: scopedInfo?.modelID,
dataMutation: "loadNew",
dataMutationAPI: overrideLoadNewAddr ? overrideLoadNewAddr : this.masterWorkingModel.scopedOptions.apis[scope]?.loadNew, //for scope can be undefined cause can be paginator. So, implemented in loadNew only
apiOptions: this.masterAPIOptions,
options: options,
mappedDataId: scopedInfo?.mappedDataId,
_get_not_orderedViewManagers: () => { return comparator.ofViews() },
networkInterface: overrideNetworkInterface ? overrideNetworkInterface : this.masterWorkingModel.scopedOptions.apis[scope].networkInterface,
loadNewMutationStateCb: (mutationState, finalData, commitCompleteCB) => {
if(mutationState === "onCommit"){
if(scope === DataManager._MODEL_ROOT_SCOPE){
//Dealing with edge cases where you are paginating, end of data
//So, response 200 with no data however
if(finalData){
//create and commit the bulk models
if(Array.isArray(finalData) && finalData.length){ //last check to ensure we don't commit an empty array
const modelIDs = this.bulkCreateModels(finalData, { skipViewHooks: true });
modelIDs.forEach((id) => {
this.commitModel(id, scope, null);
});
commitCompleteCB(modelIDs);
} else {
const modelID = this.createModel(finalData, { skipViewHooks: true });
this.commitModel(modelID, scope, null);
commitCompleteCB(modelID);
}
} else {
commitCompleteCB(null);
}
} else {
this.overwriteModel(scopedInfo.modelID, finalData, scope);
this.commitModel(scopedInfo.modelID, scope, this.getOrderedArrayIndicesForMappedDataId(scope, scopedInfo.mappedDataId));
commitCompleteCB(scopedInfo.modelID);
}
}
},
loadNewCompleteCb: (loadedData, err) => {
if(err){
reject(err);
} else {
loadedData = structuredClone(loadedData);
this.updateWatchers("loadNew", scope, loadedData, null);
resolve({
committedModel: loadedData ? structuredClone(loadedData) : null,
requestedMutation: "loadNew",
previousMutation: operationStatus.previousMutation,
status: "invoked",
msg: "Mutation invoked and completed.",
modelId: scopedInfo?.modelID,
mappedDataId: null
});
}
}
});
} else {
//Failed. Tell of failure
reject({
requestedMutation: "loadNew",
previousMutation: operationStatus.previousMutation,
status: "denied",
msg: operationStatus.msg//"Mutation denied. Waiting for ongoing mutation on this model to complete"
});
}
});
});
}
/**
* overrideNetworkInterface allows developer to pass data in more specific scope, but have it balooned to
* the one provided network interface scope. Just makes life easier.
* @type {DataManagerInstance<M>['updateModel']}
*/
updateModel(scope, modelID, newData, mappedDataId, options, overrideNetworkInterface, overrideUpdateAddr, overrideNetworkInterfaceScope, mutationStartCb){
const oldModel = this.getModel(modelID);
//Scope data correctly if override
newData = this.getNewDataScopedToRequest(overrideNetworkInterfaceScope, scope, newData, mappedDataId);
const finalScope = overrideNetworkInterfaceScope ? overrideNetworkInterfaceScope : scope;
const updateAddr = overrideUpdateAddr ? overrideUpdateAddr : this.masterWorkingModel.scopedOptions.apis[finalScope].update;
const oldData = this.getScopedModel(finalScope, mappedDataId, modelID, false);
return this.runDataMutation("update", scope, modelID, newData, options, mappedDataId, (operationStatus, runNonNetworkCancel, comparator) => {
//Now, do the update
//Doesn't create new models since update is for existing
return new Promise(async (resolve, reject) => {
if(operationStatus.operable){
this.updateDataPipeline.updateData({
scope: finalScope,
originalScope: scope,
modelID_s: modelID,
//Provide oldCompleteModel here
oldCompleteModel: oldModel,
newData: newData,
oldData: oldData,
dataMutation: "update",
//Goes straight to cancel mode
cancelBuildOnly_ByPassNonNetwork: runNonNetworkCancel,
dataMutationAPI: updateAddr,
apiOptions: this.masterAPIOptions,
options: options,
mappedDataId: mappedDataId,
_get_not_orderedViewManagers: () => { return comparator.ofViews() },
/**
* @param {string} id Only expect one id here
* NOTE: APIScope is the finalScope.
*/
mutationStateUpdate: (mutationState, id, APIScope, model) => {
const targetModel = this.masterWorkingModel.masterModels.find((model) => model.modelID === id);
if(!targetModel){
console.error("CRITICAL DATA MANAGER ERROR: Target model not found while ongoing mutation")
return;
}
if(mutationState !== "complete"){
if(APIScope === "MODEL_ROOT"){
targetModel.data.temp.master.state = mutationState;
} else {
targetModel.data.temp.scoped[APIScope].state = mutationState;
}
}
if(mutationState === "onMutate"){
//Tell observers request has started
this.informObserversOfMutationState(scope, (observer) => {
observer.onReqStart(updateAddr, "update", scope);
});
} else if(mutationState === "onCommit"){
//FLUSHING MODEL OLD AFTER UI HOOKS HAVE BEEN CALLED
//SO, TOMORROW - BETTER FLOW FOR UI HOOKS, FLUSH AFTER. ERROR WITH OPTION TO SHOW ERROR UI THROUGH VIEW MANAGER
//FOR NODE WITH MODEL ID USING RESPONSE AND MUTATION (FIGURE OUT HOW TO DO IT). IF RETRY, CHOOSE TO REINVOKE
//BUILD START HOOK FOR MUTATION? MAYBE.
//Deal with the error UI yourself. Putting it and removing it
//DON'T USE THE APIScope. Use the originalScope
//Sort it out in the pipeline actually
this.overwriteModel(id, model, APIScope);
this.commitModel(id, APIScope, this.getOrderedArrayIndicesForMappedDataId(APIScope, mappedDataId));
//Tell observers
this.informObserversOfMutationState(scope, (observer) => {
observer.onReqEnd(updateAddr, "update", scope);
});
} else if(mutationState === "complete"){
//Tell view manager watchers
//Doing this so that they're the last to get updates
if(this.masterWorkingModel.dataWatchers[APIScope]){
this.masterWorkingModel.dataWatchers[APIScope].viewManagers.forEach((manager) => {
manager.onExternalWatchCommit("update", model, null);
});
}
} else if(mutationState === "onError"){
//Tell observers. They don't respect skipUIHooks? Yes.
this.informObserversOfMutationState(scope, (observer) => {
observer.onReqErr(updateAddr, "update", scope);
});
} else if(mutationState === "onCancel"){
//HANDLE CANCELLATIONS
//Tell observers
this.informObserversOfMutationState(scope, (observer) => {
observer.onReqCancel(updateAddr, "update", scope);
});
}
},
completeCb: (modelID, scope, finalModel, err, overrideScopeCommitModel) => {
//Operation done. Remove from data operations stack after resolving
if(!err){
//Update watchers. Makes sense here. Sure pipeline is done correctly
this.updateWatchers("update", scope, finalModel, null);
//Resolve if no error
resolve({
committedModel: finalModel !== undefined ? structuredClone(finalModel) : null,
overrideScopeCommitModel: overrideScopeCommitModel !== undefined ? structuredClone(overrideScopeCommitModel) : null,
requestedMutation: "update",
previousMutation: operationStatus.previousMutation,
status: "invoked",
msg: "Mutation invoked and completed.",
modelId: modelID,
mappedDataId: mappedDataId
});
} else {
reject(err);
}
},
networkInterface: overrideNetworkInterface ? overrideNetworkInterface : this.masterWorkingModel.scopedOptions.apis[finalScope].networkInterface,
});
mutationStartCb ? mutationStartCb() : null;
} else {
//Failed. Tell of failure
reject({
requestedMutation: "update",
previousMutation: operationStatus.previousMutation,
status: "denied",
msg: operationStatus.msg//"Mutation denied. Waiting for ongoing mutation on this model to complete"
});
}
});
});
}
/**
* Directly commits - but ensure integrity despite being non-network?. So, run well
* @type {DataManagerInstance<M>['silentUpdateModel']}
*/
silentUpdateModel(scope, modelID, newData, updateUI = false, mappedDataId){
const model = this.getModel(modelID);
/**
* @type {Partial<M>}
*/
const oldModel = structuredClone(model);
return this.runDataMutation("update", scope, modelID, newData, { skipViewHooks: !updateUI }, mappedDataId, (operationStatus, runNonNetworkCancel, comparator) => {
return new Promise((resolve, reject) => {
//Doesn't use network. So, runs only if operable and not a nonNetworkCancel
if(operationStatus.operable && !runNonNetworkCancel){
if(model){
this.overwriteModel(modelID, newData, scope);
this.commitModel(modelID, scope, this.getOrderedArrayIndicesForMappedDataId(scope, mappedDataId));
if(updateUI){
this.getAllViewManagersForScope(scope).forEach((manager) => {
manager.onCommit("update", newData, this.getScopedModelFromRef(scope, this.getOrderedArrayIndicesForMappedDataId(scope, mappedDataId), oldModel, false), mappedDataId, modelID, scope, scope, () => {});
});
}
//Trigger updates for views in scope
resolve({
committedModel: this.getModel(modelID),
requestedMutation: "update",
previousMutation: operationStatus.previousMutation,
status: "invoked",
msg: "Silent Update Mutation invoked and completed.",
modelId: modelID,
mappedDataId: mappedDataId
});
} else {
console.error(`FAILED TO SILENT UPDATE. Model with model ${modelID} not found. Update value:`)
reject(`FAILED TO SILENT UPDATE. Model with model ${modelID} not found. Update value: ${newData}`);
}
} else {
reject(`FAILED TO SILENT UPDATE. Flush all event happened or host lifecycle destroyed\n. Msg: ${operationStatus.msg}`);
}
});
});
}
/**
* @type {DataManagerInstance<M>['deleteData']}
*/
deleteData(scope, modelId, mappedDataId, options, overrideNetworkInterface, overrideDeleteAddr, overrideNetworkInterfaceScope, newData){
const oldModel = this.getModel(modelId);
//Scope data correctly if override
newData = newData ? this.getNewDataScopedToRequest(overrideNetworkInterfaceScope, scope, newData, mappedDataId) : newData;
const finalScope = overrideNetworkInterfaceScope ? overrideNetworkInterfaceScope : scope;
const oldData = this.getScopedModel(finalScope, mappedDataId, modelId, false);
/**
* @type {string}
*/
const deleteAddr = overrideDeleteAddr ? overrideDeleteAddr : this.masterWorkingModel.scopedOptions.apis[finalScope].delete;
return this.runDataMutation("delete", scope, modelId, newData, options, mappedDataId, (operationStatus, runNonNetworkCancel, comparator) => {
//Now, do the update
//Doesn't create new models since update is for existing
return new Promise(async (resolve, reject) => {
if(operationStatus.operable){
this.deleteDataPipeline.deleteData({
scope: finalScope,
originalScope: scope,
modelID_s: modelId,
//Provide oldCompleteModel here
oldCompleteModel: oldModel,
newData: newData,
oldData: oldData,
dataMutation: "delete",
//Goes straight to cancel mode
cancelBuildOnly_ByPassNonNetwork: runNonNetworkCancel,
dataMutationAPI: deleteAddr,
apiOptions: this.masterAPIOptions,
options: options,
mappedDataId: mappedDataId,
_get_not_orderedViewManagers: () => { return comparator.ofViews() },
/**
* @param {string} id Only expect one id here
* APIScope is the original scope
*/
mutationStateUpdate: (mutationState, id, APIScope, model) => {
const targetModel = this.masterWorkingModel.masterModels.find((model) => model.modelID === id);
if(!targetModel && mutationState !== "complete"){
console.error("CRITICAL DATA MANAGER ERROR: Target model not found while ongoing mutation")
return;
}
if(mutationState !== "complete"){
if(APIScope === "MODEL_ROOT"){
targetModel.data.temp.master.state = mutationState;
} else {
targetModel.data.temp.scoped[APIScope].state = mutationState;
}
}
if(mutationState === "onMutate"){
//Tell observers request has started
//THIS SHOULD USE THE finalScope that the APIOptions is running
this.informObserversOfMutationState(scope, (observer) => {
observer.onReqStart(deleteAddr, "delete", scope);
});
} else if(mutationState === "onCommit"){
//FLUSHING MODEL OLD AFTER UI HOOKS HAVE BEEN CALLED
//Deal with the error UI yourself. Putting it and removing it
if(scope === DataManager._MODEL_ROOT_SCOPE){
//delete this model
this.deleteCompleteModel(modelId);
} else {
this.overwriteModel(id, model, APIScope);
this.commitModel(id, APIScope, this.getOrderedArrayIndicesForMappedDataId(APIScope, mappedDataId));
}
//Tell observers
this.informObserversOfMutationState(scope, (observer) => {
observer.onReqEnd(deleteAddr, "delete", scope);
});
} else if(mutationState === "complete"){
//Tell view manager watchers
//Doing this so that they're the last to get updates
if(this.masterWorkingModel.dataWatchers[APIScope]){
this.masterWorkingModel.dataWatchers[APIScope].viewManagers.forEach((manager) => {
manager.onExternalWatchCommit("delete", model, null);
});
}
} else if(mutationState === "onError"){
//Tell observers. They don't respect skipUIHooks? Yes.
this.informObserversOfMutationState(scope, (observer) => {
observer.onReqErr(deleteAddr, "delete", scope);
});
} else if(mutationState === "onCancel"){
//HANDLE CANCELLATIONS
//Tell observers
this.informObserversOfMutationState(scope, (observer) => {
observer.onReqCancel(deleteAddr, "delete", scope);
});
}
},
completeCb: (modelID, scope, finalModel, err, overrideScopeCommitModel) => {
//Operation done. Remove from data operations stack after resolving
if(!err){
//Update watchers. Makes sense here. Sure pipeline is done correctly
this.updateWatchers("delete", scope, finalModel, null);
//Resolve if no error
resolve({
committedModel: finalModel !== undefined ? structuredClone(finalModel) : null,
overrideScopeCommitModel: overrideScopeCommitModel !== undefined ? structuredClone(overrideScopeCommitModel) : null,
requestedMutation: "delete",
previousMutation: operationStatus.previousMutation,
status: "invoked",
msg: "Mutation invoked and completed."
});
} else {
reject(err);
}
},
networkInterface: overrideNetworkInterface ? overrideNetworkInterface : this.masterWorkingModel.scopedOptions.apis[finalScope].networkInterface,
});
} else {
//Failed. Tell of failure
reject({
requestedMutation: "delete",
previousMutation: operationStatus.previousMutation,
status: "denied",
msg: operationStatus.msg
});
}
});
})
}
/**
* @template {keyof DataManagerInstance<M>['masterWorkingModel']['scopedOptions']['apis']} ReqScope
* @template {NestedParentKeysOf<M, ReqScope>} OverrideScope
*
* @param {OverrideScope} overrideNetworkInterfaceScope
* @param {ReqScope} scope
* @param {ValueTypeOfNested<M, ReqScope>} newData
* @param {string} mappedDataId
*
* @returns {ValueTypeOfNested<M, ReqScope> | ValueTypeOfNested<M, OverrideScope>}
*/
/**
*
* @type {DataManagerInstance<M>['getNewDataScopedToRequest']}
*/
getNewDataScopedToRequest(targetScope, currentScope, newData, mappedDataId){
if(targetScope){
if(currentScope !== DataManager._MODEL_ROOT_SCOPE){
//Spawn a partial shell
//Then narrow it to overrideNetworkInterface scope
const holder = {};
//Create a partial starting from root
this.spawnPartialShellModel(currentScope, newData, holder, mappedDataId);
//Reduce that partial's value to the override scope, so reduction in view managers can work well.
newData = this.getScopedModelFromRef(targetScope, this.getOrderedArrayIndicesForMappedDataId(currentScope, mappedDataId), holder, false);
} else {
//Just reduce to overrideNetworkInterfaceScope
newData = this.getScopedModelFromRef(targetScope, this.getOrderedArrayIndicesForMappedDataId(currentScope, mappedDataId), newData, false);
}
}
return newData;
}
/**
* A helper method to ensure all mutations follow a basic or given flow for mutation execution.
* Help homogenize future updates, and better trace mutation errors, on a global scope
*
* FOR SCOPE: Always ensure it is the original scope to ensure data integrity passes are done well
* Now including mappedDataId per scope to cover for individual array changes, and make them asynchronous
*
* @todo ABOVE HAS A PROBLEM cause we can't trace a mapped data id to an individual view manager
* to avoid side effects. However, I have a solution. For later (use uniqueIds - auto generated)
*
* Because of the override scope and how it affects data access, onDataLoadPostProcess can allow you to
* sort of commit extra data to this scope. However, code disallows this by using original scope (before,
* found it out by bug where MODEL_ROOT scope temp was null. Thus merge to old failed.)
*
* Now, algo STRICTLY commit to original scope. So, allow free use of override to avoid writing same API options
* severally. For extra data you want to commit that comes from server, silent update once
*
* @type {import("DataManager").runDataMutation<M>}
*/
async runDataMutation(mutation, scope, modelID, newData, options, mappedDataId, mutationRunner){
//Get the current stamp. Useful for checking if flushAll event has occured thus delayed start of mutation
//due to promise generation and execution cycle, should run in appropriate cancellation mode
const currentRecordsStamp = this.dataRecordsStamp;
//start accepting mappedDataId and add to scope if provided, to scope operations. Reject appropriately
//i.e if same mappedDataId or model root (no mapped id) or mappedDataId from different view manager (using unique id) => reject
return new Promise(async (resolve, reject) => {
let operationStatus = null;
let runNonNetworkCancel = false;
operationStatus = await this.preProcessDataOperation(currentRecordsStamp, modelID, mutation, scope, newData, options.skipViewHooks, mappedDataId);
//After the asynchronous event above, flushAll event might have happened. Check here
const flushAllEventHasHappened = currentRecordsStamp !== this.dataRecordsStamp || this.dataRecordsStamp === DataManager._RECORDS_AFTER_LIFECYCLE_DETACH_OR_FLUSH_ALL;
//Trigger operations based on flushAllEvent policies
//Enforced here is that loadNew is always denied
if(flushAllEventHasHappened){
const specificPolicy = mutation === "delete" ? this.maintainNetworkOnFlushAll_Specific?.delete :
mutation === "update" ? this.maintainNetworkOnFlushAll_Specific?.update :
mutation === "upload" || mutation === "uploadNew" ? this.maintainNetworkOnFlushAll_Specific?.upload :
false;
//Flush all event has occured. Promise triggered after it and thus should be running correct cancellation procedure
runNonNetworkCancel = this.maintainNetworkOnFlushAll_Global ? this.maintainNetworkOnFlushAll_Global : specificPolicy;
operationStatus = { operable: runNonNetworkCancel, previousMutation: operationStatus?.previousMutation };
//Operates
}
//Run the mutation
let res = null;
let fail = null;
try {
res = await mutationRunner(operationStatus, runNonNetworkCancel, this.comparator(scope));
} catch(err){
fail = err;
}
//Operation done. Remove from data operations stack
this.onPostDataOperation(currentRecordsStamp, modelID, scope, mappedDataId);
if(res){
resolve(res);
} else {
reject(fail);
}
});
}
/**
* @param {keyof DataManagerInstance<M>['masterWorkingModel']['scopedOptions']['apis']} scope
* @param {genericParamFunction<import("DataManager").NetworkInterfaceObserver<M>>} cb
*/
informObserversOfMutationState(scope, cb){
if(this.masterWorkingModel.scopedOptions.apis[scope]?.observers){
this.masterWorkingModel.scopedOptions.apis[scope].observers.forEach((observer) => {
cb(observer);
});
}
}
/**
*
* @type {DataManagerInstance<M>['bulkCreateModels']}
*/
bulkCreateModels(newModels, options){
const newModelIds = [];
newModels.forEach((model) => {
newModelIds.push(this.createModel(model, options));
});
return newModelIds;
}
/**
* Creates a new model and commits new data to it
* @type {DataManagerInstance<M>['createModel']}
*
*/
createModel(newData, options = {}){
const modelID = RandomNumberCharGenUtils.generateRandomNumChar(8);
this.masterWorkingModel.masterModels.push({
modelID: modelID,
data: {
temp: {
master: {
data: newData,
mutation: "create",
state: null,
uiSkipped: null
},
scoped: {}
},
//Committing directly, but existence of same temp with create mutation tells its was created
committed: {}//Object.create(null), - null creating bad reference, especially for silent updates after create, in a non-MODEL_ROOT scope, since using different algos
},
});
//Call view hooks
if(!options?.skipViewHooks){
for(let scope in this.masterWorkingModel.scopedOptions.views){
this.masterWorkingModel.scopedOptions.views[scope].viewManagers.forEach((manager) => {
//Passing everything else null because irrelevant. It's a mandatory flush
manager.onCommit("create", newData, null, null, modelID, DataManager._MODEL_ROOT_SCOPE, DataManager._MODEL_ROOT_SCOPE, () => {});
});
}
}
return modelID;
}
/**
* @type {DataManagerInstance<M>['createAndCommitModel']}
*/
createAndCommitModel(newData, options){
const modelID = this.createModel(newData, options);
this.commitModel(modelID, "MODEL_ROOT", null);
return modelID;
}
/**
* CHECK WARNING AT COMMIT
*
* So, if working on whole model, to update well, we need an algo change
* Need to go nest deep. Based on committed
* Change values only given explicitly in temp
*
* recursive on keys going one level deep, node (no child), go through all keys for it,
* then return for the parent to finish.
*
* So, depth-first search?*
*
* @type {DataManagerInstance<M>['commitModel']}
*/
commitModel(modelID, scope, orderedArrayIndices){
//Our reference
const model = this.masterWorkingModel.masterModels.find((masterModel) => masterModel.modelID === modelID);
if(scope === "MODEL_ROOT"){
this.valueBasedRecursiveObjectMerge(model.data.committed, model.data.temp.master.data, orderedArrayIndices?.copy());
model.data.temp.master = {
data: null,
mutation: null,
state: null,
uiSkipped: null
};
} else {
//Committing scoped data is somewhat different. Need to commit well from root to parent.
//Basically, get current from root to leaf, add to leaf, commit to main
model.data.committed = this.mergeScopedDataToModel(model.data.committed, scope, model.data.temp.scoped[scope].data, orderedArrayIndices?.copy());
model.data.temp.scoped[scope] = null;
}
}
/**
* ONLY CALL FOR MATCHING OBJECT TYPES, WHERE YOU'RE USING SPREAD OPERATOR TO MERGE VALUES AND PROPERTIES
*
* Merges new model into old model
* @param {object} oldModel
* @param {object} newModel
* @param {QueueInstance<number>} orderedArrayIndices
* @param {boolean} [afterFirstRun]
*/
valueBasedRecursiveObjectMerge(oldModel, newModel, orderedArrayIndices, afterFirstRun){
/**
* @type {object}
*/
let copyNewModel = newModel;
if(!afterFirstRun){
copyNewModel = structuredClone(newModel);
}
for(let copyKey in copyNewModel){
if(typeof copyNewModel[copyKey] === "object" && !Array.isArray(copyNewModel[copyKey])){
if(oldModel[copyKey] === undefined || oldModel[copyKey] === null){
//Direct copy since old model didn't have it
oldModel[copyKey] = copyNewModel[copyKey];
} else {
//recursive over new child, but only if the values are different
//check helps reduce unnecessary recursion steps
//Despite the losses of stringify in complex cases, that's a worthy sacrifice
//Might use loadash, but not sure with performance
//Developer be aware of this compromise for now
/**
* @todo
*/
if(JSON.stringify(oldModel[copyKey]) !== JSON.stringify(copyNewModel[copyKey])){
this.valueBasedRecursiveObjectMerge(oldModel[copyKey], newModel[copyKey], orderedArrayIndices, true);
}
}
} else {
//checking again cause what's here might be an array or primitive/literal
if(Array.isArray(copyNewModel[copyKey])){
//FOR ARRAY. IMPORTANT
if(oldModel[copyKey] === undefined || oldModel[copyKey] === null){
//Nothing put there before. so, direct copy
oldModel[copyKey] = copyNewModel[copyKey];
} else {
//Account for empty indices. Not doing that led me to a very interesting bug
if(!orderedArrayIndices || orderedArrayIndices.length === 0){
// No ordered array indices provided. Merging everything to this key. Old data overwritten
oldModel[copyKey] = newModel[copyKey];
} else {
//Work on the indices given, relating to where the mutation was triggered
const index = orderedArrayIndices.dequeue();
if(!orderedArrayIndices.isEmpty()){
copyNewModel[copyKey][index] = oldModel[copyKey][index];
this.valueBasedRecursiveObjectMerge(oldModel[copyKey][index], copyNewModel[copyKey], orderedArrayIndices, true)
} else {
console.warn("Fun fact. In JavaScript, null >= 0 is true. What in the 💀");
//THIS TOOK ME FOUR HOURS BRO!
//and yes, the first console is a deliberate warning. Save yourself 💀
//now those queue need to spew out undefined. F
//This will only happen for indices greater than newModel OR
//a specific index in new model that was deleted (changed to null or undefined) after
//the delete operation
if(index >= 0 && (copyNewModel[copyKey][index] === null || copyNewModel[copyKey][index] === undefined)){
//splice this value. It's being deleted
oldModel[copyKey].splice(index, 1);
} else {
oldModel[copyKey][index] = copyNewModel[copyKey][index];
}
}
}
}
} else {
//Not an array, and not nested. Direct copy
oldModel[copyKey] = copyNewModel[copyKey];
}
}
}
}
/**
*
* @type {DataManagerInstance<M>['commitBulkModels']}
*/
commitBulkModels(options){
options.forEach((option) => {
this.commitModel(option.modelID, option.scope, null);
});
}
/**
* Overwrite temp in model to new value
*
* @type {DataManagerInstance<M>['overwriteModel']}
*/
overwriteModel(modelID, data, scope){
//Our reference. Do the referencing here. Perfect!!!!
const targetMasterModel = this.masterWorkingModel.masterModels.find((masterModel) => masterModel.modelID === modelID);
if(targetMasterModel){
if(scope === "MODEL_ROOT"){
//Not taking ordered array indices cause merging for scoped temp, which is directly at value
this.valueBasedRecursiveObjectMerge(targetMasterModel.data.temp.master.data, data, null);
} else {
//Prepare the scope data object
if(!targetMasterModel.data.temp.scoped[scope]){
//@ts-expect-error
targetMasterModel.data.temp.scoped[scope] = { data: {} };
}
//Should be a warning we can ignore
//Saved directly cause working with same values
if(typeof data === "object"){
//if array, just put direct. Else, recursive merge
if(Array.isArray(data)){
targetMasterModel.data.temp.scoped[scope].data = data;
} else {
this.valueBasedRecursiveObjectMerge(targetMasterModel.data.temp.scoped[scope].data, data, null);
}
} else {
targetMasterModel.data.temp.scoped[scope].data = data;
}
}
} else {
console.error(`Failed to overwrite model with model ID ${modelID}. Not found`);
}
}
/**
* CONFIRM THE ALGO
* @type {DataManagerInstance<M>['mergeScopedDataToModel']}
*/
mergeScopedDataToModel(model, scope, value, orderedArrayIndices){
const splitScope = scope.toString().split(DataManager._NESTED_SCOPE_KEY_SPLITTER);
//Reduce to before leaf (so that we don't get value of terminal and maintain by reference actions)
const leaf = splitScope[splitScope.length - 1];
//doing one before leaf so that we have a reference that will work even for literals (always gets us an object)
const oneBeforeLeaf = this.recursiveValueReference(scope, null, model, orderedArrayIndices, null, true);
//Spawn it if doesn't exist (doing this long check cause of booleans)
if(oneBeforeLeaf[leaf] === undefined || oneBeforeLeaf[leaf] === null){
oneBeforeLeaf[leaf] = {};
}
if(typeof value === "object"){
if(!Array.isArray(oneBeforeLeaf[leaf])){ //!Array.isArray(oneBeforeLeaf)
this.valueBasedRecursiveObjectMerge(oneBeforeLeaf[leaf], value, orderedArrayIndices);
} else {
//We are dealing with an array
// Pushing or deleting values directly since it was not decomposed to a specific member using orderedArrayIndices sourced from the viewNode
if(Object.keys(value).length === 0){
//if empty object, we remove everything in array
oneBeforeLeaf[leaf].splice(0, oneBeforeLeaf[leaf].length);
} else {
oneBeforeLeaf[leaf].push(...value);
}
}
} else {
if(Array.isArray(oneBeforeLeaf[leaf])){
oneBeforeLeaf[leaf].push(value);
} else {
oneBeforeLeaf[leaf] = value;
}
}
return model;
}
/**
*
* @type {DataManagerInstance<M>['flushModelTemp']}
*/
flushModelTemp(modelID, scope){
const targetMasterModel = this.masterWorkingModel.masterModels.find((masterModel) => {
masterModel.modelID === modelID;
});
if(targetMasterModel && targetMasterModel.data.committed){
if(scope === "MODEL_ROOT"){
targetMasterModel.data.temp.master.data = null;
} else {
//Not removing object cause can be used later to see last mutation on a scope
targetMasterModel.data.temp.scoped[scope].data = null;
}
} else {
const mutation = scope === "MODEL_ROOT" ? targetMasterModel.data.temp.master.mutation : targetMasterModel.data.temp.scoped[scope].mutation;
console.error(`Failed to flush temp in model with model ID ${modelID}. Nothing committed from mutation ${mutation}`);
}
}
/**
* FINISH CANCELLATIONS
*
* REJECT IF NEW OPERATION PARENT OR CHILD IN SCOPE TO RUNNING (SO LET INTERNAL ONES FINISH FIRST AS PER MODEL OBJECT)
* As per data operations override behavior
*
* @type { DataManagerInstance<M>['preProcessDataOperation'] }
*
*/
preProcessDataOperation(recordsStamp, modelID, requestedMutation, requestedScope, newData, skipUI, mappedDataId){
return new Promise(async (resolve) => {
const dataOperationsStack = this.getValidDataOperationsStack(recordsStamp, true);
const runningOperationInfo = dataOperationsStack.find((info) => info.modelID === modelID );
/**
* The cancelling here is absolute, so doesn't respect network continue, since
* you're explicitly stopping it
*
* @param {import("DataManager").OperationInfo<M>} operation
* @returns {Promise<import("DataManager").DataOperationsStatus<M>>}
*/
const cancelRunningOperation = async (operation) => {
return new Promise(async (resolve, reject) => {
//Cancelling the current operation
//Return on callback to proceed. So, use a promise? Yes
if(operation.mutation === "loadNew"){
//Cancel the LOAD process and resolve
await this.loadNewDataPipeline.cancelNewDataLoad(modelID, operation.scope);
resolve({
operable: true,
info: runningOperationInfo,
previousMutation: operation.mutation,
msg: `Mutation accepted. Cancelled previous load for model ID: ${runningOperationInfo.modelID} and scope: ${operation.scope}`
});
} else if(operation.mutation === "update"){
//Cancel the UPDATE process
await this.updateDataPipeline.cancelDataUpdate(modelID, operation.scope);
resolve({
operable: true,
info: runningOperationInfo,
previousMutation: operation.mutation,
msg: `Mutation accepted. Cancelled previous update for model ID: ${runningOperationInfo.modelID} and scope: ${operation.scope}`
});
} else if(operation.mutation === "upload" || operation.mutation === "uploadNew"){
//Cancel the UPLOAD process
await this.uploadDataPipeline.cancelDataUpload(runningOperationInfo.modelID, operation.scope);
resolve({
operable: true,
info: runningOperationInfo,
previousMutation: operation.mutation,
msg: `Mutation accepted. Cancelled previous upload for model ID: ${runningOperationInfo.modelID} and scope: ${operation.scope}`
});
} else if(operation.mutation === "delete"){
//Cancel the DELETE process
await this.deleteDataPipeline.cancelDataDelete(runningOperationInfo.modelID, operation.scope);
resolve({
operable: true,
info: runningOperationInfo,
previousMutation: operation.mutation,
msg: `Mutation accepted. Cancelled previous delete for model ID: ${runningOperationInfo.modelID} and scope: ${operation.scope}`
});
} else {
console.error("Mutation bad call: Should not be asynchronous. Intercepted by preprocessor");
resolve({
operable: false,
info: runningOperationInfo,
previousMutation: null,
msg: `Mutation denied. Bad code. Intercepted mutation that should NOT be in operations stack: ${requestedMutation}`
});
}
});
}
const mappedScope = this.getMappedDataOperationScope(requestedScope, mappedDataId);
if(runningOperationInfo){
//If scope is the same, refuse
const sameScope = runningOperationInfo.operations.find((operation) => operation.scope === mappedScope);
if(sameScope){
if(this.dataOperationsOverrideBehavior === "wait"){
resolve({
operable: false,
info: runningOperationInfo,
previousMutation: sameScope.mutation,
msg: `Mutation denied. Waiting for similar mutation of scope ${requestedScope.toString()} on this model to complete`
});
} else {
//Make cancellations
const status = await cancelRunningOperation(sameScope);
if(status.operable){
//Update to new mutation
//Delete previous? Assuming it's by reference direct change should work. Test that
const previousMutation = sameScope.mutation;
sameScope.mutation = requestedMutation;
//Test. REMOVE IF IT WORKS
if(runningOperationInfo.operations.find((operation) => operation === sameScope).mutation === requestedMutation){
console.warn("BY REFERENCE WORKED!!! REMOVE ME AND ELSE BLOCK BELOW");
} else {
runningOperationInfo.operations.splice(runningOperationInfo.operations.findIndex((scope) => scope === sameScope), 1);
runningOperationInfo.operations.push(sameScope);
}
updateMutationState(this.masterWorkingModel.masterModels.find((model) => model.modelID === modelID), requestedScope);
resolve({
info: runningOperationInfo,
operable: true,
previousMutation: previousMutation,
msg: `Mutation invoked. Previous mutation of same scope ${mappedScope} cancelled`
});
} else {
resolve({
info: runningOperationInfo,
operable: false,
previousMutation: sameScope.mutation,
msg: `Mutation denied with message: ${status.msg}`
});
}
}
} else {
//Scope not the same.
//So, creating new operation
//However, new scope should not be parent or child of any running scope
//Also, if there's a single running operation with scope other than MODEL_ROOT and scope requested
//is model root, reject
//Check latter first then former
const belowRootScopeRunning = runningOperationInfo.operations.find((operation) => operation.scope !== "MODEL_ROOT");
if(belowRootScopeRunning && mappedScope === "MODEL_ROOT"){
resolve({
operable: false,
info: runningOperationInfo,
previousMutation: null,
msg: `Mutation denied. Pending operations of lower scope to model root running on the model`
});
} else {
//If requested scope is MODEL_ROOT, just accept immediately
if(mappedScope === "MODEL_ROOT"){
/**
* @type {import("DataManager").OperationInfo<M>}
*/
const newOperation = {
mutation: requestedMutation,
scope: mappedScope
}
runningOperationInfo.operations.push(newOperation);
//Purposefully using requested scope here to keep 1 to 1 reference in model
updateMutationState(this.masterWorkingModel.masterModels.find((model) => model.modelID === modelID), requestedScope);
resolve({
operable: true,
info: runningOperationInfo,
previousMutation: null,
msg: `Mutation invoked for root model under the MODEL_ROOT scope: ${mappedScope}`
});
} else {
/**
* @type {DataManagerMutations}
*/
let prevMutation = null;
//Requested scope not model root, so before accept, ensure not child or parent of ANY scope, including
//itself if using mappedID
try {
runningOperationInfo.operations.forEach((operation) => {
//Check child
const opScope = operation.scope;
if(this.getDe_MappedOperationScope(mappedScope).startsWith(this.getDe_MappedOperationScope(opScope))){
//Requested scope is child of a running scope. Stop
prevMutation = operation.mutation;
throw new Error("child: " + mappedScope + " :: " + opScope);
} else {
//Check parent
if(this.getDe_MappedOperationScope(opScope).startsWith(this.getDe_MappedOperationScope(mappedScope))){
//Requested scope is parent to running scope
prevMutation = operation.mutation;
throw new Error("parent");
}
}
//What inspired this bit of code:
//Look at this - https://stackoverflow.com/questions/58434389/typescript-deep-keyof-of-a-nested-object'
//AND THIS - https://medium.com/xgeeks/typescript-utility-keyof-nested-object-fa3e457ef2b2
//https://www.typescriptlang.org/docs/handbook/2/template-literal-types.html
});
//Not child or parent. Accepted
//Also, only mutation happening
updateMutationState(this.masterWorkingModel.masterModels.find((model) => model.modelID === modelID), requestedScope);
resolve({
operable: true,
info: runningOperationInfo,
previousMutation: null,
msg: `Mutation invoked for scoped model under the scope: ${mappedScope}`
});
} catch(err){
resolve({
operable: false,
info: runningOperationInfo,
previousMutation: prevMutation,
msg: `Mutation denied. Scope of requested operations ${err} to a running operation. Data integrity bottleneck`
});
}
}
}
}
} else {
//No info. Create new
/**
* @type {import("DataManager").DataOperationsInfo<M>}
*/
const info = {
modelID: modelID,
operations: [{
mutation: requestedMutation,
scope: mappedScope
}]
};
//Push to stack
dataOperationsStack.push(info);
updateMutationState(this.masterWorkingModel.masterModels.find((model) => model.modelID === modelID), requestedScope);
resolve({
operable: true,
info: info,
previousMutation: null,
msg: `Mutation invoked for scoped model under the scope: ${mappedScope}. Fresh invocation`
});
}
/**
* Make sure your scope is NOT MAPPED
*
* @param {import("DataManager").DataManagerMasterModel<M>} masterModel
* @param {keyof DataManagerInstance<M>['masterWorkingModel']['scopedOptions']['apis']} scope
*/
function updateMutationState(masterModel, scope){
//For load new, master model can be null. So, must check
if(masterModel){
if(scope === "MODEL_ROOT"){
masterModel.data.temp.master.mutation = requestedMutation;
masterModel.data.temp.master.data = newData ? newData : {},
masterModel.data.temp.master.uiSkipped = skipUI;
masterModel.data.temp.master.state = "onMutate";
} else {
if(!masterModel.data.temp.scoped[requestedScope]){
//@ts-expect-error
masterModel.data.temp.scoped[requestedScope] = {};
}
masterModel.data.temp.scoped[requestedScope].mutation = requestedMutation;
masterModel.data.temp.scoped[requestedScope].data = newData ? newData : {},
masterModel.data.temp.scoped[requestedScope].uiSkipped = skipUI;
masterModel.data.temp.scoped[requestedScope].state = "onMutate";
}
}
}
});
}
/**
*
* @param {string} recordsStamp
* @param {boolean} [buildNew]
* @returns {Stack<import("DataManager").DataOperationsInfo<M>>}
*/
getValidDataOperationsStack(recordsStamp, buildNew){
let dataOperationsStack = this.dataOperationsRecords.get(recordsStamp);
if(!dataOperationsStack){
//The records under this stamp have been moved to lifecycle reject or change (merge the stacks)
//So, find it there. Either a flushAll event occured or host destroyed (lifecycle event)
dataOperationsStack = this.dataOperationsRecords.get(DataManager._RECORDS_AFTER_LIFECYCLE_DETACH_OR_FLUSH_ALL);
if(!dataOperationsStack){
//First of its kind, lol
dataOperationsStack = new Stack();
}
if(buildNew){
this.dataOperationsRecords.set(recordsStamp, dataOperationsStack);
}
}
return dataOperationsStack;
}
/**
*
* @type {DataManagerInstance<M>['onPostDataOperation']}
*/
onPostDataOperation(recordsStamp, modelID, scope, mappedDataId){
const mappedScope = this.getMappedDataOperationScope(scope, mappedDataId);
const dataOperationsStack = this.getValidDataOperationsStack(recordsStamp);
const operationInfo = dataOperationsStack.find((info) => info.modelID === modelID);
if(operationInfo?.operations.length === 1){
dataOperationsStack.sortDelete(operationInfo);
} else {
//Just splice the operation for the scope
operationInfo?.operations.splice(operationInfo.operations.findIndex((info) => info.scope === mappedScope), 1);
}
}
static get _FOR_MAPPED_OP_SCOPE_KEYWORD(){
return "_FOR_MAPPED_";
}
/**
* @template {keyof DataManagerInstance<M>['masterWorkingModel']['scopedOptions']['apis']} ReqScope
* @param {ReqScope} scope
* @param {string} mappedDataId
* @returns {string}
*/
getMappedDataOperationScope(scope, mappedDataId){
return mappedDataId ? `${scope}${DataManager._FOR_MAPPED_OP_SCOPE_KEYWORD}${mappedDataId}` : scope;
}
/**
* Demaps a scope. mappedDataId goes LAST in mapping, after _FOR_MAPPED_ keyword.
*
* @param {string} mappedScope
*/
getDe_MappedOperationScope(mappedScope){
const splitScope = mappedScope.split(`${DataManager._FOR_MAPPED_OP_SCOPE_KEYWORD}`);
if(mappedScope.includes(`${DataManager._FOR_MAPPED_OP_SCOPE_KEYWORD}`)){
//Remove _FOR_MAPPED_ keyword and the mappedDataId
splitScope.splice(splitScope.length - 1, 1);
}
return splitScope.join("");
}
/**
* Returns copy of stored model (COMMITTED)
* Copy to avoid external manipulation not using data manager channels
* @type {DataManagerInstance<M>['getModel']}
*/
getModel(modelID){
const targetModel = this.masterWorkingModel.masterModels.find((model) => model.modelID === modelID);
if(targetModel){
return structuredClone(targetModel.data.committed);
}
return null;
}
/**
* Change to getModelInPosition to rhyme with queue?
* @type {DataManagerInstance<M>['getModelInIndex']}
*/
getModelInIndex(index){
if(index >= 0 && this.masterWorkingModel.masterModels[index]){
return structuredClone(this.masterWorkingModel.masterModels[index].data.committed);
}
return null;
}
/**
*
* @type {DataManagerInstance<M>['getModelId']}
*/
getModelId(index){
if(index >= 0 && this.masterWorkingModel.masterModels[index]){
return this.masterWorkingModel.masterModels[index].modelID;
}
return null;
}
/**
*
* @type {DataManagerInstance<M>['getScopedModel']}
*/
getScopedModel(scope, mappedDataId, modelID, stopAtNodeforRef){
const model = this.getModel(modelID);
if(scope === DataManager._MODEL_ROOT_SCOPE){
return model;
}
return this.recursiveValueReference(scope, null, model, this.getOrderedArrayIndicesForMappedDataId(scope, mappedDataId), null, stopAtNodeforRef);
}
/**
* If it return undefined, this property doesn't exist in target model
* @type {DataManagerInstance<M>['reduceModelToProperties']}
*/
reduceModelToProperties(baseModel, targetModel){
const reducedModel = {};
let foundProp = false;
for(const key in baseModel){
if(targetModel[key]){
foundProp = true;
reducedModel[key] = targetModel[key];
}
}
return foundProp ? reducedModel : undefined;
}
/**
* ACCESS THIS IF AND ONLY IF YOU'VE REDUCED YOUR SCOPE TO THE DATA SPACE
*
* @type {DataManagerInstance<M>['getScopedModelFromRef']}
*/
getScopedModelFromRef(scope, orderedArrayIndices, model, stopAtNodeforRef){
if(scope === DataManager._MODEL_ROOT_SCOPE){
return model;
} else {
return this.recursiveValueReference(scope, null, model, orderedArrayIndices, null, stopAtNodeforRef);
}
}
/**
* Use this to recursively reference a model and its type.
* Not as straightforward especially when working with arrays
*
* TO MAKE WORK EASIER, MANAGER HOLDS REFERENCE TO WHERE CHANGE SHOULD HAPPEN. CHANGES THAT TO NEW VALUE.
* BETTER SOLUTION TBF
* IF OBJECT, MERGE. IF LITERAL, OVERWRITE. YES.
* @template {ValueTypeOfNested<M, DataScope>} MainModel
* @template {NestedRelativeChildKeyOf<M, DataScope>} ReqScope
* @template {NestedKeyOf<M>} DataScope
* @param {ReqScope} scope
* @param {string} prevKey
* @param {ViewManagerOrderedArrayIndices} orderedArrayIndices This provided by view managers. Can infer its parent for index of its model id to create right order. So, keeping child list which can also be used to invoke a new build for recyclable lists
* @param {ValueTypeOfNested<MainModel, ReqScope>} referencedModel
* @param {MainModel} mainModel
* @param {boolean} stopAtNodeforRef
* @returns {ValueTypeOfNested<MainModel, ReqScope> | ValueTypeOfArrayOnly<M, ReqScope>} //Returning value type of array too, if deconstructed
*/
recursiveValueReference(scope, prevKey, mainModel, orderedArrayIndices, referencedModel, stopAtNodeforRef = false){
if(!mainModel){
return referencedModel;
}
//Set referenced model to main model - starting procedure
if(!referencedModel){
//Setting it like this, to cater with scope split to length 1. Should point back to main model
referencedModel = mainModel;
}
if(orderedArrayIndices){
orderedArrayIndices = orderedArrayIndices.copy();
}
if(scope){
const keys = scope.toString().split(DataManager._NESTED_SCOPE_KEY_SPLITTER);
const key = keys.splice(0, 1)[0];
//Only move to (reference) next if not stopping at node and at end of keys
if(keys.length === 0 && stopAtNodeforRef){
return referencedModel;
} else{
if(key === DataManager._SCOPED_ARRAY_LITERAL || key === DataManager._ARRAY_SELF_TYPE){
//referenced model now moves to the type at the ordered index of the main model
//Main model now an array cause of referencing at recursion
const orderedIndex = orderedArrayIndices?.dequeue();
//Decomposing array only if ordered indices provided. Else, returning whole array
//to which you'll push(add new);
if(orderedIndex !== null){
referencedModel = mainModel[orderedIndex];
} else {
//No ordered index for the array. So only reference the array, if not referencing self type
//If reference self type, only get first in array
referencedModel = key === DataManager._ARRAY_SELF_TYPE ? mainModel[0] : mainModel;
}
} else {
//reference just directly from mainModel
referencedModel = mainModel[key];
}
if(referencedModel !== undefined){ //was just referenceModel. Failing with false values
// passing referenced model because it is now the equivalent of mainModel referenced by key or array index
return this.recursiveValueReference(keys.length > 0 ? keys.join(DataManager._NESTED_SCOPE_KEY_SPLITTER) : null, key, referencedModel, orderedArrayIndices, referencedModel, stopAtNodeforRef);
} else {
return undefined;
}
}
} else {
return referencedModel;
}
}
/**
* Children scope for view also updated. YESSS
* Okay, no. Might be separating some concerns. Fire only relevant scope.
* @type {DataManagerInstance<M>['setViewManager']}
*/
setViewManager(scope, viewManager, initArgs = null){
//Prep for lifecycle handling
//Remove view manager from stored list if lifecycle triggered
viewManager.getLifeCycleInstance().registerLifeCycleListeners({
onFragmentCancelled: () => {},
onFragmentRunning: () => {},
onFragmentDestroyed: () => {
if(this.masterWorkingModel.scopedOptions.views[scope]){
if(this.masterWorkingModel.scopedOptions.views[scope].viewManagers.findIndex((manager) => manager.id === viewManager.id) !== -1){
this.masterWorkingModel.scopedOptions.views[scope].viewManagers.splice(
this.masterWorkingModel.scopedOptions.views[scope].viewManagers.findIndex((manager) => manager.id === viewManager.id),
1)
} else {
console.error(`DATA MANAGER ERROR: View manager of scope ${scope} not found`)
}
} else {
console.error(`DATA MANAGER ERROR: Whole scope of view manager of scope ${scope} never put as an entry`);
}
}
});
//Add to scoped options
this.masterWorkingModel.scopedOptions.views = {
...this.masterWorkingModel.scopedOptions.views,
[scope] : {
viewManagers: this.masterWorkingModel.scopedOptions.views[scope] && this.masterWorkingModel.scopedOptions.views[scope].viewManagers ? this.masterWorkingModel.scopedOptions.views[scope].viewManagers.concat([viewManager]) : [viewManager]
}
}
//Tell it directly to init
viewManager.initViewManager(initArgs);
}
/**
*
* @type {DataManagerInstance<M>['getViewManager']}
*/
getViewManager(scope, id){
if(this.masterWorkingModel.scopedOptions.views[scope]){
return this.masterWorkingModel.scopedOptions.views[scope].viewManagers.find((manager) => manager.id === id);
}
return null;
}
/**
*
* @param {keyof DataManagerInstance<M>['masterWorkingModel']['scopedOptions']['views']} scope
*/
getAllViewManagersForScope(scope){
/**
* @type {StandardViewManagerInstance<M, *>[]}
*/
let potentialViewManagers = [];
if(scope === "MODEL_ROOT"){
//return ALL available ones
for(let viewScope in this.masterWorkingModel.scopedOptions.views){
//concat to list. Just load all as long as is parent
potentialViewManagers = potentialViewManagers.concat(this.masterWorkingModel.scopedOptions.views[viewScope].viewManagers);
}
//return a set, cause self can register multiple times
return Array.from(new Set(potentialViewManagers));
} else {
//NOT SCOPE MODEL_ROOT.
//So, get direct, parent, and children of THAT scope
//This is different from ALL scopes in MODEL ROOT
//e.g { me: { have: { this: "todo" } }, and: "this" } (scopes = me | me.have | me.have.this | and).
//because of how component hooks work, a mutation to scope me.have can have view effects in
//view manager of scope me and me.have.this
//because, view manager of scope me can have component hooks of me.have | me.have.this
//and view manager of scope me.have.this may have root hooks of that scope, and mutation can affect that data
//root hooks are basically component hooks of SAME SCOPE
if(this.masterWorkingModel.scopedOptions.views[scope]){
// save the direct first
potentialViewManagers = potentialViewManagers.concat(this.masterWorkingModel.scopedOptions.views[scope].viewManagers);
}
//Now get parents of scope and children
/**
* @type {NestedKeyOf<M>}
*/
let viewScope = null;
for(viewScope in this.masterWorkingModel.scopedOptions.views){
//parents first
if(scope.startsWith(viewScope)){
//concat to list. Just load all as long as is parent
potentialViewManagers = potentialViewManagers.concat(this.masterWorkingModel.scopedOptions.views[viewScope].viewManagers);
} else if(viewScope.startsWith(scope)){
//child
potentialViewManagers = potentialViewManagers.concat(this.masterWorkingModel.scopedOptions.views[viewScope].viewManagers);
}
}
//Add model root as last, if it exists. Won't be picked up by algo above, but should be called regardless cause of overarching scope
if(this.masterWorkingModel.scopedOptions.views.MODEL_ROOT?.viewManagers){
//transfer above here. But want to get why we don't have view managers for scope
potentialViewManagers = potentialViewManagers.concat(this.masterWorkingModel.scopedOptions.views.MODEL_ROOT?.viewManagers);
}
//Now, turn into a set and return
potentialViewManagers = Array.from(new Set(potentialViewManagers));
//turn into a set btw
return potentialViewManagers;
}
}
/**
* @template {keyof DataManagerInstance<M>['masterWorkingModel']['dataWatchers']} S
* @param {DataManagerMutations} mutation
* @param {S} scope
* @param {Partial<ValueTypeOfNested<M, S>> | Partial<ValueTypeOfNested<M, S>>[]} newData
* @param {Partial<ValueTypeOfNested<M, S>> | Partial<ValueTypeOfNested<M, S>>[]} oldData
*/
updateWatchers(mutation, scope, newData, oldData){
if(this.masterWorkingModel.dataWatchers[scope]){
this.masterWorkingModel.dataWatchers[scope].viewManagers.forEach((manager) => {
manager.onExternalWatchCommit(mutation, newData, oldData);
})
};
}
/**
*
* @type {DataManagerInstance<M>['comparator']}
*/
comparator(scope){
const scopedViewOptions = this.masterWorkingModel.scopedOptions.views;
//READ CAPS BELOW. USE THIS FOR SAME LOGIC
/**
*
* @param {NestedKeyOf<M> | _ScopeModelRoot} scope
* @returns
*/
const getAllViewManagersForScope = (scope) => this.getAllViewManagersForScope(scope);
return new(class {
constructor(){
/**
* @type {import("DataManager").ScopeComparatorInterfaceInstance<M>['scope']}
*/
this.scope = scope;
}
/**
* @type {import("DataManager").ScopeComparatorInterfaceInstance<M>['ofViews']}
*/
ofViews(){
/**
* USE THE SAME LOGIC AS getViewManagers(scope)
*
* AND THEN, ONLY DELETE OR FLUSH ALL (which affects view) IF DIRECT AFFECTED IN CALL (SCOPE OF CALL EQUALS IT), OR IS CHILD OF SCOPE CALL
*
* ELSE, BOUNCE AND TRIGGER CHILDREN. MIGHT BE A CHILD, SINCE YOU'RE PARENT TO
* THAT SCOPE
*
* SELF TYPE ONLY USED IN HOOKS. Else, nope (because of its reference to self)
*/
return getAllViewManagersForScope(this.scope);
}
});
}
/**
* Creates a partial shell model of the target reference model, fulfilling the scope from root
*
* Added targetReferenceModel because spread operator is breaking with nested objects
* @type {DataManagerInstance<M>['spawnPartialShellModel']}
*/
spawnPartialShellModel(scope, value, targetReferenceModel, mappedDataId){
const fullScope = scope;
/**
* I think you're useless. We're just creating man
* @type {QueueInstance<number>}
*/
const nodeOrderedArrayIndices = mappedDataId ? this.getOrderedArrayIndicesForMappedDataId(scope, mappedDataId) : new Queue();
/**
* Use this to recursively reference a model and its type.
* Not as straightforward especially when working with arrays
*
* TO MAKE WORK EASIER, MANAGER HOLDS REFERENCE TO WHERE CHANGE SHOULD HAPPEN. CHANGES THAT TO NEW VALUE.
* BETTER SOLUTION TBF
* IF OBJECT, MERGE. IF LITERAL, OVERWRITE. YES.
* @template {NestedKeyOf<M>} RefScope
* @param {RefScope} scope
* @param {string[]} prevKeys
* @param {QueueInstance<number>} spawnedOrderedArrayIndices This provided by view managers. Can infer its parent for index of its model id to create right order. So, keeping child list which can also be used to invoke a new build for recyclable lists
* @param {DataManagerInstance<M>} dataManagerInstance
* @param {Partial<M>} spawnedModel
* @returns {Partial<M>}
*/
function createModelRecursive(scope, prevKeys, spawnedModel, spawnedOrderedArrayIndices, dataManagerInstance){
if(scope){
if(!spawnedModel){
spawnedModel = targetReferenceModel ? targetReferenceModel : Object.create(null);
}
if(!spawnedOrderedArrayIndices){
spawnedOrderedArrayIndices = new Queue();
}
if(!prevKeys){
prevKeys = [];
}
const keys = scope.toString().split(DataManager._NESTED_SCOPE_KEY_SPLITTER);
//Only move to (reference) next if have keys
const key = keys.splice(0, 1)[0];
if(key === DataManager._SCOPED_ARRAY_LITERAL){
if(prevKeys.length > 0){
//Get node, so that we can define the type of the leaf instead of getting the default leaf type put there in previous setting (which for this case, was none array)
//If part of array inside, will spawn to index 0, and update that node
let node = dataManagerInstance.getScopedModelFromRef(prevKeys.join(DataManager._NESTED_SCOPE_KEY_SPLITTER), spawnedOrderedArrayIndices, spawnedModel, false);
//Now at array. Need to index it
if(nodeOrderedArrayIndices && nodeOrderedArrayIndices.length > 0 && node.length){
node = node[nodeOrderedArrayIndices.dequeue()];
} else {
node[0] = {};
//spawned is for one we've set, not original
//If viewNode provided, only node's ordered used
spawnedOrderedArrayIndices.enqueue(0);
}
} else {
console.error("DATA MANAGER ERROR: When spawning model, must already have previous keys before reaching an array")
}
} else {
//Normal reference
//reference just directly from mainModel
let node = {};
if(prevKeys.length > 0){
//After root, rest set by reference
//Not getting node at previous key because that was set. We want to set after it. So, value is object which is a ref, that we can override
node = dataManagerInstance.getScopedModelFromRef(prevKeys.join(DataManager._NESTED_SCOPE_KEY_SPLITTER), spawnedOrderedArrayIndices, spawnedModel, false);
//instantiate predictive. if next key is array, then [], else {}
//then figure out what to do for actual array (do next key thing. Yeaa..)
node[key] = node[key] ? node[key] : keys[0] === DataManager._SCOPED_ARRAY_LITERAL ? [] : {}; //Also instantiating into an object to make it easier to work with and create references
} else {
//First value
spawnedModel[key] = spawnedModel[key] ? spawnedModel[key] : keys[0] === DataManager._SCOPED_ARRAY_LITERAL ? [] : {};
}
}
prevKeys.push(key);
return createModelRecursive(keys.length > 0 ? keys.join(DataManager._NESTED_SCOPE_KEY_SPLITTER) : null, prevKeys, spawnedModel, spawnedOrderedArrayIndices, dataManagerInstance);
} else {
//Now, fix the value
if(value !== undefined && value !== null){
const fullScopeSplit = fullScope.toString().split(DataManager._NESTED_SCOPE_KEY_SPLITTER);
const ref = dataManagerInstance.getScopedModelFromRef(fullScope, spawnedOrderedArrayIndices, spawnedModel, true);
ref[fullScopeSplit[fullScopeSplit.length - 1]] = value;
}
//Because of reference issues, do this
return spawnedModel;
}
}
createModelRecursive(scope, null, null, null, this);
}
/**
*
* @param {keyof DataManagerInstance<M>['masterWorkingModel']['scopedOptions']['views']} scope
* @param {string} mappedDataId
*/
getOrderedArrayIndicesForMappedDataId(scope, mappedDataId){
let viewManagers = this.getAllViewManagersForScope(scope);
/**
* @type {QueueInstance<number>}
*/
let orderedArrayIndices = null;
if(viewManagers){
for(let i = 0; i < viewManagers.length; i++){
orderedArrayIndices = viewManagers[i].getOrderedArrayIndices(scope, mappedDataId);
if(orderedArrayIndices?.length > 0){
break;
}
}
}
return orderedArrayIndices;
}
/**
*
* @type {DataManagerInstance<M>['setScopedAPIOptions']}
*/
setScopedAPIOptions(scope, options){
if(this.masterWorkingModel.scopedOptions.apis[scope]){
console.warn("DATA MANAGER: Overriding scoped API options for scope " + scope);
}
if(options[scope].observers){
options[scope].observers = null;
console.error("DATA MANAGER: Setting observers directly with API options forbidden. Potential memory leaks.\n\nUse setScopedAPIDataOpInterfaceObserver after setting your scoped API options. Scope: " + scope);
return;
}
this.masterWorkingModel.scopedOptions.apis = { ...this.masterWorkingModel.scopedOptions.apis, ...options }
}
/**
*
* @type {DataManagerInstance<M>['setScopedAPIDataOpInterfaceObserver']}
*/
setScopedAPIDataOpInterfaceObserver(scope, networkInterfaceObserver, lifecycleInstance){
if(!this.masterWorkingModel.scopedOptions.apis[scope]){
console.error("DATA MANAGER: Cannot set a network interface observer for a scope without a set network interface. Scope " + scope.toString());
return;
}
this.masterWorkingModel.scopedOptions.apis[scope].observers = this.masterWorkingModel.scopedOptions.apis[scope].observers ? this.masterWorkingModel.scopedOptions.apis[scope].observers.concat(networkInterfaceObserver) : [networkInterfaceObserver];
lifecycleInstance.registerLifeCycleListeners({
onFragmentRunning: () => {},
onFragmentCancelled: () => {},
onFragmentDestroyed: () => {
this.masterWorkingModel.scopedOptions.apis[scope].observers.splice(this.masterWorkingModel.scopedOptions.apis[scope].observers.findIndex((observer) => observer === networkInterfaceObserver), 1);
}
});
}
/**
*
* @type {DataManagerInstance<M>['flushAllData']}
*/
flushAllData(options){
const flushCurrentModel = () => {
//Generate new records stamp
this.generateDataRecordsStamp();
this.masterWorkingModel.masterModels.forEach((masterModel) => {
oldModels.push(masterModel.data.committed);
oldModelIds.push(masterModel.modelID);
});
//clear in array
this.masterWorkingModel.masterModels = [];
//Tell ALL view managers attached
for(let scope in this.masterWorkingModel.scopedOptions.views){
this.masterWorkingModel.scopedOptions.views[scope].viewManagers.forEach((manager) => {
//Passing everything else null because irrelevant. It's a mandatory flush
manager.onCommit("delete_FlushAll", oldModels, oldModels, null, oldModelIds, DataManager._MODEL_ROOT_SCOPE, DataManager._MODEL_ROOT_SCOPE, () => {});
});
}
//Tell all observers
for(let scope in this.masterWorkingModel.dataWatchers){
this.masterWorkingModel.dataWatchers[scope].viewManagers.forEach((manager) => {
manager.onExternalWatchCommit("delete_FlushAll", null, null);
});
}
//Cleared all data
}
/**
* @type {M[]}
*/
const oldModels = [];
/**
* @type {string[]}
*/
const oldModelIds = [];
//Ensure operation is permitted
if(!this.getValidDataOperationsStack(this.dataRecordsStamp).isEmpty()){
if(!options || !options.cancelAllPendingOperations){
//This is to redundantly inform you that cancellations MUST be made. So remember if you chose network to persist
console.error("DATA MANAGER: Can't flush all model data. Pending operations not cancelled");
return null;
}
//Cancel all operations
this.cancelAllDataOperations();
flushCurrentModel();
} else {
flushCurrentModel();
}
return oldModels;
}
/**
* Passing MODEL_ROOT for modelId and scope will flush all data
*
* Else, a modelId MUST have a valid scope
*
* Cancels ALL data operations for the given scope
*
* @type {DataManagerInstance<M>['flushScopedData']}
*/
flushScopedData(modelId, scope, mappedDataId){
/**
* This will respect the network policy given the nature of overriding
*
* User driven, and in interest of getting new data. Works similar to flushAllData
*/
const cancelAllPendingOperationsForScope = () => {
const runningOperationRecord = this.dataOperationsRecords.get(this.dataRecordsStamp);
const runningOperationInfo = runningOperationRecord.find((info) => info.modelID === modelId);
if(runningOperationInfo){
/**
* @type {import("DataManager").OperationInfo<M>}
*/
const spliceOperations = [];
runningOperationInfo.operations.forEach((operation) => {
//Cancel for exact and children
if(this.getDe_MappedOperationScope(operation.scope) === scope || this.getDe_MappedOperationScope(operation.scope).startsWith(scope)){
if(operation.mutation === "delete"){
const maintainNetwork = this.maintainNetworkOnFlushAll_Global ? this.maintainNetworkOnFlushAll_Global : this.maintainNetworkOnFlushAll_Specific?.delete;
this.deleteDataPipeline.cancelDataDelete(modelId, scope, false, maintainNetwork);
} else if(operation.mutation === "loadNew"){
this.loadNewDataPipeline.cancelNewDataLoad(modelId, scope);
} else if(operation.mutation === "update"){
const maintainNetwork = this.maintainNetworkOnFlushAll_Global ? this.maintainNetworkOnFlushAll_Global : this.maintainNetworkOnFlushAll_Specific?.update;
this.updateDataPipeline.cancelDataUpdate(modelId, scope, false, maintainNetwork);
} else if(operation.mutation === "upload" || operation.mutation === "uploadNew"){
const maintainNetwork = this.maintainNetworkOnFlushAll_Global ? this.maintainNetworkOnFlushAll_Global : this.maintainNetworkOnFlushAll_Specific?.upload;
this.uploadDataPipeline.cancelDataUpload(modelId, scope, false, maintainNetwork);
}
//Add to list of operations to remove
spliceOperations.push(operation);
}
});
//Delete operations
spliceOperations.forEach((operation) => {
runningOperationInfo.operations.splice(runningOperationInfo.operations.findIndex((op) => op === operation), 1);
});
//If operations for this operation info is empty, delete the entry
if(runningOperationInfo.operations.length === 0){
runningOperationRecord.sortDelete(runningOperationInfo);
}
}
}
const updateViewManagers = () => {
const allViewManagers = this.comparator(scope).ofViews();
allViewManagers.forEach((manager) => {
manager.onCommit("delete_FlushAll", null, this.getScopedModel(scope, null, modelId, false), mappedDataId, modelId, scope, scope, () => {});
});
}
if(this.dataRecordsStamp !== DataManager._RECORDS_AFTER_LIFECYCLE_DETACH_OR_FLUSH_ALL){
if(modelId === DataManager._MODEL_ROOT_SCOPE && scope === DataManager._MODEL_ROOT_SCOPE){
this.flushAllData({ cancelAllPendingOperations: true });
return true;
} else if(modelId !== DataManager._MODEL_ROOT_SCOPE){
//Try and get model
const model = this.getModel(modelId);
if(model){
//Find any pending operations for given scope
cancelAllPendingOperationsForScope();
//Update the view managers
updateViewManagers();
//Now override and commit the null
this.overwriteModel(modelId, null, scope);
this.commitModel(modelId, scope, null);
return true;
} else {
return false;
}
}
}
}
/**
*
* @param {string} modelId
*/
deleteCompleteModel(modelId){
const index = this.masterWorkingModel.masterModels.findIndex((model) => model.modelID = modelId);
if(index !== -1){
this.masterWorkingModel.masterModels.splice(index, 1);
} else {
console.warn("Couldn't completely delete model with id " + modelId + ". Not found");
}
}
/**
* @type {DataManagerInstance<M>['hasData']}
*/
hasData(){
return this.masterWorkingModel.masterModels.length > 0;
}
/**
* @type {DataManagerInstance<M>['dataLength']}
*/
dataLength(){
return this.masterWorkingModel.masterModels.length;
}
}
if(false){
/**
* @type {import("DataManager").DataManagerConstructor<*>}
*/
const dataManager = DataManager;
}
export default DataManager;