1/* 2 * Copyright (C) 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 19/** 20 * A proxy class that allows you to create objects that are backed by a persistent store. 21 * The proxy will automatically save changes made to the object to the store. 22 */ 23export class PersistentStoreProxy { 24 static new<T extends object>( 25 key: string, 26 defaultState: T, 27 storage: Store, 28 ): T { 29 const storedState = JSON.parse(storage.get(key) ?? '{}', parseMap); 30 const currentState = mergeDeep({}, structuredClone(defaultState)); 31 mergeDeepKeepingStructure(currentState, storedState); 32 return wrapWithPersistentStoreProxy(key, currentState, storage) as T; 33 } 34} 35 36function wrapWithPersistentStoreProxy( 37 storeKey: string, 38 object: object, 39 storage: Store, 40 baseObject: object = object, 41): object { 42 const updatableProps: string[] = []; 43 44 for (const [key, value] of Object.entries(object)) { 45 if ( 46 typeof value === 'string' || 47 typeof value === 'boolean' || 48 typeof value === 'number' || 49 value === undefined 50 ) { 51 if (!Array.isArray(object)) { 52 updatableProps.push(key); 53 } 54 } else { 55 (object as any)[key] = wrapWithPersistentStoreProxy( 56 storeKey, 57 value, 58 storage, 59 baseObject, 60 ); 61 } 62 } 63 const proxyObj = new Proxy(object, { 64 set: (target, prop, newValue) => { 65 if (typeof prop === 'symbol') { 66 throw new Error("Can't use symbol keys only strings"); 67 } 68 if ( 69 Array.isArray(target) && 70 (typeof prop === 'number' || !Number.isNaN(Number(prop))) 71 ) { 72 target[Number(prop)] = newValue; 73 storage.add(storeKey, JSON.stringify(baseObject, stringifyMap)); 74 return true; 75 } 76 if (!Array.isArray(target) && Array.isArray(newValue)) { 77 (target as any)[prop] = wrapWithPersistentStoreProxy( 78 storeKey, 79 newValue, 80 storage, 81 baseObject, 82 ); 83 storage.add(storeKey, JSON.stringify(baseObject, stringifyMap)); 84 return true; 85 } 86 if (!Array.isArray(target) && updatableProps.includes(prop)) { 87 (target as any)[prop] = newValue; 88 storage.add(storeKey, JSON.stringify(baseObject, stringifyMap)); 89 return true; 90 } 91 throw new Error( 92 `Object property '${prop}' is not updatable. Can only update leaf keys: [${updatableProps}]`, 93 ); 94 }, 95 }); 96 97 return proxyObj; 98} 99 100function isObject(item: any): boolean { 101 return item && typeof item === 'object' && !Array.isArray(item); 102} 103 104/** 105 * Merge sources into the target keeping the structure of the target. Arrays are replaced. 106 * @param target the object we mutate by merging the data from source into, but keep the object structure of 107 * @param source the object we merge into target 108 */ 109function mergeDeepKeepingStructure(target: any, source: any) { 110 if (isObject(target) && isObject(source)) { 111 for (const key in target) { 112 if (source[key] === undefined) { 113 continue; 114 } 115 116 if (isObject(target[key]) && isObject(source[key])) { 117 mergeDeepKeepingStructure(target[key], source[key]); 118 continue; 119 } 120 121 if (!isObject(target[key]) && !isObject(source[key])) { 122 Object.assign(target, {[key]: source[key]}); 123 continue; 124 } 125 } 126 } 127} 128 129function mergeDeep(target: any, ...sources: any): object { 130 if (!sources.length) return target; 131 const source = sources.shift(); 132 133 if (isObject(target) && isObject(source)) { 134 for (const key in source) { 135 if (isObject(source[key])) { 136 if (!target[key]) Object.assign(target, {[key]: {}}); 137 mergeDeep(target[key], source[key]); 138 } else { 139 Object.assign(target, {[key]: source[key]}); 140 } 141 } 142 } 143 144 return mergeDeep(target, ...sources); 145} 146 147/** 148 * Stringify a Map object to an object with type and value properties. 149 * @param key the key of the Map object 150 * @param value the Map object 151 * @return the object with type and value properties 152 */ 153export function stringifyMap(key: string, value: any) { 154 if (value instanceof Map) { 155 return { 156 type: 'Map', 157 value: [...value], 158 }; 159 } 160 return value; 161} 162 163/** 164 * Parse a Map object from an object with type and value properties. 165 * @param key the key of the Map object 166 * @param value the object with type and value properties 167 * @return the Map object 168 */ 169export function parseMap(key: string, value: any) { 170 if (value && value.type === 'Map') { 171 return new Map(value.value); 172 } 173 return value; 174} 175