1
0

DataStore.ts 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334
  1. /* eslint-disable @typescript-eslint/no-explicit-any */
  2. //#region types
  3. /** Function that takes the data in the old format and returns the data in the new format. Also supports an asynchronous migration. */
  4. type MigrationFunc = (oldData: any) => any | Promise<any>;
  5. /** Dictionary of format version numbers and the function that migrates to them from the previous whole integer */
  6. export type DataMigrationsDict = Record<number, MigrationFunc>;
  7. /** Options for the DataStore instance */
  8. export type DataStoreOptions<TData> =
  9. & {
  10. /** A unique internal ID for this data store - choose wisely as changing it is not supported yet. */
  11. id: string;
  12. /**
  13. * The default data object to use if no data is saved in persistent storage yet.
  14. * Until the data is loaded from persistent storage with `loadData()`, this will be the data returned by `getData()`
  15. *
  16. * ⚠️ This has to be an object that can be serialized to JSON using `JSON.stringify()`, so no functions or circular references are allowed, they will cause unexpected behavior.
  17. */
  18. defaultData: TData;
  19. /**
  20. * An incremental, whole integer version number of the current format of data.
  21. * If the format of the data is changed in any way, this number should be incremented, in which case all necessary functions of the migrations dictionary will be run consecutively.
  22. *
  23. * ⚠️ Never decrement this number and optimally don't skip any numbers either!
  24. */
  25. formatVersion: number;
  26. /**
  27. * A dictionary of functions that can be used to migrate data from older versions to newer ones.
  28. * The keys of the dictionary should be the format version that the functions can migrate to, from the previous whole integer value.
  29. * The values should be functions that take the data in the old format and return the data in the new format.
  30. * The functions will be run in order from the oldest to the newest version.
  31. * If the current format version is not in the dictionary, no migrations will be run.
  32. */
  33. migrations?: DataMigrationsDict;
  34. /**
  35. * Where the data should be saved (`"GM"` by default).
  36. * The protected methods `getValue` , `setValue` and `deleteValue` are used to interact with the storage.
  37. * If you want to use a different storage method, you can extend the class and overwrite these methods.
  38. */
  39. storageMethod?: "GM" | "localStorage" | "sessionStorage";
  40. }
  41. & (
  42. | {
  43. /**
  44. * Function to use to encode the data prior to saving it in persistent storage.
  45. * If this is specified, make sure to declare {@linkcode decodeData()} as well.
  46. *
  47. * You can make use of UserUtils' [`compress()`](https://github.com/Sv443-Network/UserUtils?tab=readme-ov-file#compress) function here to make the data use up less space at the cost of a little bit of performance.
  48. * @param data The input data as a serialized object (JSON string)
  49. */
  50. encodeData: (data: string) => string | Promise<string>,
  51. /**
  52. * Function to use to decode the data after reading it from persistent storage.
  53. * If this is specified, make sure to declare {@linkcode encodeData()} as well.
  54. *
  55. * You can make use of UserUtils' [`decompress()`](https://github.com/Sv443-Network/UserUtils?tab=readme-ov-file#decompress) function here to make the data use up less space at the cost of a little bit of performance.
  56. * @returns The resulting data as a valid serialized object (JSON string)
  57. */
  58. decodeData: (data: string) => string | Promise<string>,
  59. }
  60. | {
  61. encodeData?: never,
  62. decodeData?: never,
  63. }
  64. );
  65. //#region class
  66. /**
  67. * Manages a hybrid synchronous & asynchronous persistent JSON database that is cached in memory and persistently saved across sessions using [GM storage.](https://wiki.greasespot.net/GM.setValue)
  68. * Supports migrating data from older format versions to newer ones and populating the cache with default data if no persistent data is found.
  69. *
  70. * All methods are at least `protected`, so you can easily extend this class and overwrite them to use a different storage method or to add additional functionality.
  71. * Remember that you can call `super.methodName()` in the subclass to access the original method.
  72. *
  73. * ⚠️ Requires the directives `@grant GM.getValue` and `@grant GM.setValue` if the storageMethod is left as the default of `"GM"`
  74. * ⚠️ Make sure to call {@linkcode loadData()} at least once after creating an instance, or the returned data will be the same as `options.defaultData`
  75. *
  76. * @template TData The type of the data that is saved in persistent storage for the currently set format version (will be automatically inferred from `defaultData` if not provided) - **This has to be a JSON-compatible object!** (no undefined, circular references, etc.)
  77. */
  78. export class DataStore<TData extends object = object> {
  79. public readonly id: string;
  80. public readonly formatVersion: number;
  81. public readonly defaultData: TData;
  82. public readonly encodeData: DataStoreOptions<TData>["encodeData"];
  83. public readonly decodeData: DataStoreOptions<TData>["decodeData"];
  84. public readonly storageMethod: Required<DataStoreOptions<TData>>["storageMethod"];
  85. private cachedData: TData;
  86. private migrations?: DataMigrationsDict;
  87. /**
  88. * Creates an instance of DataStore to manage a sync & async database that is cached in memory and persistently saved across sessions.
  89. * Supports migrating data from older versions to newer ones and populating the cache with default data if no persistent data is found.
  90. *
  91. * ⚠️ Requires the directives `@grant GM.getValue` and `@grant GM.setValue` if the storageMethod is left as the default of `"GM"`
  92. * ⚠️ Make sure to call {@linkcode loadData()} at least once after creating an instance, or the returned data will be the same as `options.defaultData`
  93. *
  94. * @template TData The type of the data that is saved in persistent storage for the currently set format version (will be automatically inferred from `defaultData` if not provided) - **This has to be a JSON-compatible object!** (no undefined, circular references, etc.)
  95. * @param options The options for this DataStore instance
  96. */
  97. constructor(options: DataStoreOptions<TData>) {
  98. this.id = options.id;
  99. this.formatVersion = options.formatVersion;
  100. this.defaultData = options.defaultData;
  101. this.cachedData = options.defaultData;
  102. this.migrations = options.migrations;
  103. this.storageMethod = options.storageMethod ?? "GM";
  104. this.encodeData = options.encodeData;
  105. this.decodeData = options.decodeData;
  106. }
  107. //#region public
  108. /**
  109. * Loads the data saved in persistent storage into the in-memory cache and also returns it.
  110. * Automatically populates persistent storage with default data if it doesn't contain any data yet.
  111. * Also runs all necessary migration functions if the data format has changed since the last time the data was saved.
  112. */
  113. public async loadData(): Promise<TData> {
  114. try {
  115. const gmData = await this.getValue(`_uucfg-${this.id}`, JSON.stringify(this.defaultData));
  116. let gmFmtVer = Number(await this.getValue(`_uucfgver-${this.id}`, NaN));
  117. if(typeof gmData !== "string") {
  118. await this.saveDefaultData();
  119. return { ...this.defaultData };
  120. }
  121. const isEncoded = Boolean(await this.getValue(`_uucfgenc-${this.id}`, false));
  122. let saveData = false;
  123. if(isNaN(gmFmtVer)) {
  124. await this.setValue(`_uucfgver-${this.id}`, gmFmtVer = this.formatVersion);
  125. saveData = true;
  126. }
  127. let parsed = await this.deserializeData(gmData, isEncoded);
  128. if(gmFmtVer < this.formatVersion && this.migrations)
  129. parsed = await this.runMigrations(parsed, gmFmtVer);
  130. if(saveData)
  131. await this.setData(parsed);
  132. this.cachedData = { ...parsed };
  133. return this.cachedData;
  134. }
  135. catch(err) {
  136. console.warn("Error while parsing JSON data, resetting it to the default value.", err);
  137. await this.saveDefaultData();
  138. return this.defaultData;
  139. }
  140. }
  141. /**
  142. * Returns a copy of the data from the in-memory cache.
  143. * Use {@linkcode loadData()} to get fresh data from persistent storage (usually not necessary since the cache should always exactly reflect persistent storage).
  144. * @param deepCopy Whether to return a deep copy of the data (default: `false`) - only necessary if your data object is nested and may have a bigger performance impact if enabled
  145. */
  146. public getData(deepCopy = false): TData {
  147. return deepCopy
  148. ? this.deepCopy(this.cachedData)
  149. : { ...this.cachedData };
  150. }
  151. /** Saves the data synchronously to the in-memory cache and asynchronously to the persistent storage */
  152. public setData(data: TData): Promise<void> {
  153. this.cachedData = data;
  154. const useEncoding = this.encodingEnabled();
  155. return new Promise<void>(async (resolve) => {
  156. await Promise.all([
  157. this.setValue(`_uucfg-${this.id}`, await this.serializeData(data, useEncoding)),
  158. this.setValue(`_uucfgver-${this.id}`, this.formatVersion),
  159. this.setValue(`_uucfgenc-${this.id}`, useEncoding),
  160. ]);
  161. resolve();
  162. });
  163. }
  164. /** Saves the default data passed in the constructor synchronously to the in-memory cache and asynchronously to persistent storage */
  165. public async saveDefaultData(): Promise<void> {
  166. this.cachedData = this.defaultData;
  167. const useEncoding = this.encodingEnabled();
  168. return new Promise<void>(async (resolve) => {
  169. await Promise.all([
  170. this.setValue(`_uucfg-${this.id}`, await this.serializeData(this.defaultData, useEncoding)),
  171. this.setValue(`_uucfgver-${this.id}`, this.formatVersion),
  172. this.setValue(`_uucfgenc-${this.id}`, useEncoding),
  173. ]);
  174. resolve();
  175. });
  176. }
  177. /**
  178. * Call this method to clear all persistently stored data associated with this DataStore instance.
  179. * The in-memory cache will be left untouched, so you may still access the data with {@linkcode getData()}
  180. * Calling {@linkcode loadData()} or {@linkcode setData()} after this method was called will recreate persistent storage with the cached or default data.
  181. *
  182. * ⚠️ This requires the additional directive `@grant GM.deleteValue`
  183. */
  184. public async deleteData(): Promise<void> {
  185. await Promise.all([
  186. this.deleteValue(`_uucfg-${this.id}`),
  187. this.deleteValue(`_uucfgver-${this.id}`),
  188. this.deleteValue(`_uucfgenc-${this.id}`),
  189. ]);
  190. }
  191. /** Returns whether encoding and decoding are enabled for this DataStore instance */
  192. public encodingEnabled(): this is Required<Pick<DataStoreOptions<TData>, "encodeData" | "decodeData">> {
  193. return Boolean(this.encodeData && this.decodeData);
  194. }
  195. //#region migrations
  196. /**
  197. * Runs all necessary migration functions consecutively and saves the result to the in-memory cache and persistent storage and also returns it.
  198. * This method is automatically called by {@linkcode loadData()} if the data format has changed since the last time the data was saved.
  199. * Though calling this method manually is not necessary, it can be useful if you want to run migrations for special occasions like a user importing potentially outdated data that has been previously exported.
  200. *
  201. * If one of the migrations fails, the data will be reset to the default value if `resetOnError` is set to `true` (default). Otherwise, an error will be thrown and no data will be saved.
  202. */
  203. public async runMigrations(oldData: any, oldFmtVer: number, resetOnError = true): Promise<TData> {
  204. if(!this.migrations)
  205. return oldData as TData;
  206. let newData = oldData;
  207. const sortedMigrations = Object.entries(this.migrations)
  208. .sort(([a], [b]) => Number(a) - Number(b));
  209. let lastFmtVer = oldFmtVer;
  210. for(const [fmtVer, migrationFunc] of sortedMigrations) {
  211. const ver = Number(fmtVer);
  212. if(oldFmtVer < this.formatVersion && oldFmtVer < ver) {
  213. try {
  214. const migRes = migrationFunc(newData);
  215. newData = migRes instanceof Promise ? await migRes : migRes;
  216. lastFmtVer = oldFmtVer = ver;
  217. }
  218. catch(err) {
  219. if(!resetOnError)
  220. throw new Error(`Error while running migration function for format version '${fmtVer}'`);
  221. console.error(`Error while running migration function for format version '${fmtVer}' - resetting to the default value.`, err);
  222. await this.saveDefaultData();
  223. return this.getData();
  224. }
  225. }
  226. }
  227. await Promise.all([
  228. this.setValue(`_uucfg-${this.id}`, await this.serializeData(newData)),
  229. this.setValue(`_uucfgver-${this.id}`, lastFmtVer),
  230. this.setValue(`_uucfgenc-${this.id}`, this.encodingEnabled()),
  231. ]);
  232. return this.cachedData = { ...newData as TData };
  233. }
  234. //#region serialization
  235. /** Serializes the data using the optional this.encodeData() and returns it as a string */
  236. protected async serializeData(data: TData, useEncoding = true): Promise<string> {
  237. const stringData = JSON.stringify(data);
  238. if(!this.encodingEnabled() || !useEncoding)
  239. return stringData;
  240. const encRes = this.encodeData(stringData);
  241. if(encRes instanceof Promise)
  242. return await encRes;
  243. return encRes;
  244. }
  245. /** Deserializes the data using the optional this.decodeData() and returns it as a JSON object */
  246. protected async deserializeData(data: string, useEncoding = true): Promise<TData> {
  247. let decRes = this.encodingEnabled() && useEncoding ? this.decodeData(data) : undefined;
  248. if(decRes instanceof Promise)
  249. decRes = await decRes;
  250. return JSON.parse(decRes ?? data) as TData;
  251. }
  252. //#region misc
  253. /** Copies a JSON-compatible object and loses all its internal references in the process */
  254. protected deepCopy<T>(obj: T): T {
  255. return JSON.parse(JSON.stringify(obj));
  256. }
  257. //#region storage
  258. /** Gets a value from persistent storage - can be overwritten in a subclass if you want to use something other than GM storage */
  259. protected async getValue<TValue extends GM.Value = string>(name: string, defaultValue: TValue): Promise<string | TValue> {
  260. switch(this.storageMethod) {
  261. case "localStorage":
  262. return localStorage.getItem(name) as TValue ?? defaultValue;
  263. case "sessionStorage":
  264. return sessionStorage.getItem(name) as string ?? defaultValue;
  265. default:
  266. return GM.getValue<TValue>(name, defaultValue);
  267. }
  268. }
  269. /**
  270. * Sets a value in persistent storage - can be overwritten in a subclass if you want to use something other than GM storage.
  271. * The default storage engines will stringify all passed values like numbers or booleans, so be aware of that.
  272. */
  273. protected async setValue(name: string, value: GM.Value): Promise<void> {
  274. switch(this.storageMethod) {
  275. case "localStorage":
  276. return localStorage.setItem(name, String(value));
  277. case "sessionStorage":
  278. return sessionStorage.setItem(name, String(value));
  279. default:
  280. return GM.setValue(name, String(value));
  281. }
  282. }
  283. /** Deletes a value from persistent storage - can be overwritten in a subclass if you want to use something other than GM storage */
  284. protected async deleteValue(name: string): Promise<void> {
  285. switch(this.storageMethod) {
  286. case "localStorage":
  287. return localStorage.removeItem(name);
  288. case "sessionStorage":
  289. return sessionStorage.removeItem(name);
  290. default:
  291. return GM.deleteValue(name);
  292. }
  293. }
  294. }