• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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