1/* 2 * Copyright 2022, The Android Open Source Project 3 * 4 * Licensed under the Apache License, Version 2.0 (the "License"); 5 * you may not use this file except in compliance with the License. 6 * You may obtain a copy of the License at 7 * 8 * http://www.apache.org/licenses/LICENSE-2.0 9 * 10 * Unless required by applicable law or agreed to in writing, software 11 * distributed under the License is distributed on an "AS IS" BASIS, 12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 * See the License for the specific language governing permissions and 14 * limitations under the License. 15 */ 16 17import {Store} from './store'; 18 19export class PersistentStoreProxy { 20 static new<T extends object>( 21 key: string, 22 defaultState: T, 23 storage: Store, 24 ): T { 25 const storedState = JSON.parse(storage.get(key) ?? '{}', parseMap); 26 const currentState = mergeDeep({}, structuredClone(defaultState)); 27 mergeDeepKeepingStructure(currentState, storedState); 28 return wrapWithPersistentStoreProxy(key, currentState, storage) as T; 29 } 30} 31 32function wrapWithPersistentStoreProxy( 33 storeKey: string, 34 object: object, 35 storage: Store, 36 baseObject: object = object, 37): object { 38 const updatableProps: string[] = []; 39 40 for (const [key, value] of Object.entries(object)) { 41 if ( 42 typeof value === 'string' || 43 typeof value === 'boolean' || 44 typeof value === 'number' || 45 value === undefined 46 ) { 47 if (!Array.isArray(object)) { 48 updatableProps.push(key); 49 } 50 } else { 51 (object as any)[key] = wrapWithPersistentStoreProxy( 52 storeKey, 53 value, 54 storage, 55 baseObject, 56 ); 57 } 58 } 59 const proxyObj = new Proxy(object, { 60 set: (target, prop, newValue) => { 61 if (typeof prop === 'symbol') { 62 throw new Error("Can't use symbol keys only strings"); 63 } 64 if ( 65 Array.isArray(target) && 66 (typeof prop === 'number' || !Number.isNaN(Number(prop))) 67 ) { 68 target[Number(prop)] = newValue; 69 storage.add(storeKey, JSON.stringify(baseObject, stringifyMap)); 70 return true; 71 } 72 if (!Array.isArray(target) && Array.isArray(newValue)) { 73 (target as any)[prop] = wrapWithPersistentStoreProxy( 74 storeKey, 75 newValue, 76 storage, 77 baseObject, 78 ); 79 storage.add(storeKey, JSON.stringify(baseObject, stringifyMap)); 80 return true; 81 } 82 if (!Array.isArray(target) && updatableProps.includes(prop)) { 83 (target as any)[prop] = newValue; 84 storage.add(storeKey, JSON.stringify(baseObject, stringifyMap)); 85 return true; 86 } 87 throw new Error( 88 `Object property '${prop}' is not updatable. Can only update leaf keys: [${updatableProps}]`, 89 ); 90 }, 91 }); 92 93 return proxyObj; 94} 95 96function isObject(item: any): boolean { 97 return item && typeof item === 'object' && !Array.isArray(item); 98} 99 100/** 101 * Merge sources into the target keeping the structure of the target. 102 * @param target the object we mutate by merging the data from source into, but keep the object structure of 103 * @param source the object we merge into target 104 * @return the mutated target object 105 */ 106function mergeDeepKeepingStructure(target: any, source: any): any { 107 if (isObject(target) && isObject(source)) { 108 for (const key in target) { 109 if (source[key] === undefined) { 110 continue; 111 } 112 113 if (isObject(target[key]) && isObject(source[key])) { 114 mergeDeepKeepingStructure(target[key], source[key]); 115 continue; 116 } 117 118 if (!isObject(target[key]) && !isObject(source[key])) { 119 Object.assign(target, {[key]: source[key]}); 120 continue; 121 } 122 } 123 } 124 125 return target; 126} 127 128function mergeDeep(target: any, ...sources: any): any { 129 if (!sources.length) return target; 130 const source = sources.shift(); 131 132 if (isObject(target) && isObject(source)) { 133 for (const key in source) { 134 if (isObject(source[key])) { 135 if (!target[key]) Object.assign(target, {[key]: {}}); 136 mergeDeep(target[key], source[key]); 137 } else { 138 Object.assign(target, {[key]: source[key]}); 139 } 140 } 141 } 142 143 return mergeDeep(target, ...sources); 144} 145 146export function stringifyMap(key: string, value: any) { 147 if (value instanceof Map) { 148 return { 149 type: 'Map', 150 value: [...value], 151 }; 152 } 153 return value; 154} 155 156export function parseMap(key: string, value: any) { 157 if (value && value.type === 'Map') { 158 return new Map(value.value); 159 } 160 return value; 161} 162