DataStore.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263
  1. /* eslint-disable @typescript-eslint/no-explicit-any */
  2. /** Function that takes the data in the old format and returns the data in the new format. Also supports an asynchronous migration. */
  3. type MigrationFunc = (oldData: any) => any | Promise<any>;
  4. /** Dictionary of format version numbers and the function that migrates to them from the previous whole integer */
  5. export type DataMigrationsDict = Record<number, MigrationFunc>;
  6. /** Options for the DataStore instance */
  7. export type DataStoreOptions<TData> = {
  8. /** A unique internal ID for this data store - choose wisely as changing it is not supported yet. */
  9. id: string;
  10. /**
  11. * The default data object to use if no data is saved in persistent storage yet.
  12. * Until the data is loaded from persistent storage with `loadData()`, this will be the data returned by `getData()`
  13. *
  14. * ⚠️ This has to be an object that can be serialized to JSON, so no functions or circular references are allowed, they will cause unexpected behavior.
  15. */
  16. defaultData: TData;
  17. /**
  18. * An incremental, whole integer version number of the current format of data.
  19. * 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.
  20. *
  21. * ⚠️ Never decrement this number and optimally don't skip any numbers either!
  22. */
  23. formatVersion: number;
  24. /**
  25. * A dictionary of functions that can be used to migrate data from older versions to newer ones.
  26. * The keys of the dictionary should be the format version that the functions can migrate to, from the previous whole integer value.
  27. * The values should be functions that take the data in the old format and return the data in the new format.
  28. * The functions will be run in order from the oldest to the newest version.
  29. * If the current format version is not in the dictionary, no migrations will be run.
  30. */
  31. migrations?: DataMigrationsDict;
  32. }
  33. & ({
  34. /**
  35. * Function to use to encode the data prior to saving it in persistent storage.
  36. * If this is specified, make sure to declare {@linkcode decodeData()} as well.
  37. *
  38. * 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.
  39. * @param data The input data as a serialized object (JSON string)
  40. */
  41. encodeData: (data: string) => string | Promise<string>,
  42. /**
  43. * Function to use to decode the data after reading it from persistent storage.
  44. * If this is specified, make sure to declare {@linkcode encodeData()} as well.
  45. *
  46. * 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.
  47. * @returns The resulting data as a valid serialized object (JSON string)
  48. */
  49. decodeData: (data: string) => string | Promise<string>,
  50. } | {
  51. encodeData?: never,
  52. decodeData?: never,
  53. });
  54. /**
  55. * 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)
  56. * Supports migrating data from older format versions to newer ones and populating the cache with default data if no persistent data is found.
  57. *
  58. * ⚠️ Requires the directives `@grant GM.getValue` and `@grant GM.setValue`
  59. * ⚠️ Make sure to call {@linkcode loadData()} at least once after creating an instance, or the returned data will be the same as `options.defaultData`
  60. *
  61. * @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.)
  62. */
  63. export class DataStore<TData extends object = object> {
  64. public readonly id: string;
  65. public readonly formatVersion: number;
  66. public readonly defaultData: TData;
  67. public readonly encodeData: DataStoreOptions<TData>["encodeData"];
  68. public readonly decodeData: DataStoreOptions<TData>["decodeData"];
  69. private cachedData: TData;
  70. private migrations?: DataMigrationsDict;
  71. /**
  72. * Creates an instance of DataStore to manage a sync & async database that is cached in memory and persistently saved across sessions.
  73. * Supports migrating data from older versions to newer ones and populating the cache with default data if no persistent data is found.
  74. *
  75. * ⚠️ Requires the directives `@grant GM.getValue` and `@grant GM.setValue`
  76. * ⚠️ Make sure to call {@linkcode loadData()} at least once after creating an instance, or the returned data will be the same as `options.defaultData`
  77. *
  78. * @template TData The type of the data that is saved in persistent storage (will be automatically inferred from `options.defaultData`) - this should also be the type of the data format associated with the current `options.formatVersion`
  79. * @param options The options for this DataStore instance
  80. */
  81. constructor(options: DataStoreOptions<TData>) {
  82. this.id = options.id;
  83. this.formatVersion = options.formatVersion;
  84. this.defaultData = options.defaultData;
  85. this.cachedData = options.defaultData;
  86. this.migrations = options.migrations;
  87. this.encodeData = options.encodeData;
  88. this.decodeData = options.decodeData;
  89. }
  90. /**
  91. * Loads the data saved in persistent storage into the in-memory cache and also returns it.
  92. * Automatically populates persistent storage with default data if it doesn't contain any data yet.
  93. * Also runs all necessary migration functions if the data format has changed since the last time the data was saved.
  94. */
  95. public async loadData(): Promise<TData> {
  96. try {
  97. const gmData = await GM.getValue(`_uucfg-${this.id}`, this.defaultData);
  98. let gmFmtVer = Number(await GM.getValue(`_uucfgver-${this.id}`, NaN));
  99. if(typeof gmData !== "string") {
  100. await this.saveDefaultData();
  101. return { ...this.defaultData };
  102. }
  103. const isEncoded = await GM.getValue(`_uucfgenc-${this.id}`, false);
  104. let saveData = false;
  105. if(isNaN(gmFmtVer)) {
  106. await GM.setValue(`_uucfgver-${this.id}`, gmFmtVer = this.formatVersion);
  107. saveData = true;
  108. }
  109. let parsed = await this.deserializeData(gmData, isEncoded);
  110. if(gmFmtVer < this.formatVersion && this.migrations)
  111. parsed = await this.runMigrations(parsed, gmFmtVer);
  112. if(saveData)
  113. await this.setData(parsed);
  114. this.cachedData = { ...parsed };
  115. return this.cachedData;
  116. }
  117. catch(err) {
  118. console.warn("Error while parsing JSON data, resetting it to the default value.", err);
  119. await this.saveDefaultData();
  120. return this.defaultData;
  121. }
  122. }
  123. /**
  124. * Returns a copy of the data from the in-memory cache.
  125. * Use {@linkcode loadData()} to get fresh data from persistent storage (usually not necessary since the cache should always exactly reflect persistent storage).
  126. */
  127. public getData(): TData {
  128. return this.deepCopy(this.cachedData);
  129. }
  130. /** Saves the data synchronously to the in-memory cache and asynchronously to the persistent storage */
  131. public setData(data: TData): Promise<void> {
  132. this.cachedData = data;
  133. const useEncoding = this.encodingEnabled();
  134. return new Promise<void>(async (resolve) => {
  135. await Promise.all([
  136. GM.setValue(`_uucfg-${this.id}`, await this.serializeData(data, useEncoding)),
  137. GM.setValue(`_uucfgver-${this.id}`, this.formatVersion),
  138. GM.setValue(`_uucfgenc-${this.id}`, useEncoding),
  139. ]);
  140. resolve();
  141. });
  142. }
  143. /** Saves the default data passed in the constructor synchronously to the in-memory cache and asynchronously to persistent storage */
  144. public async saveDefaultData(): Promise<void> {
  145. this.cachedData = this.defaultData;
  146. const useEncoding = this.encodingEnabled();
  147. return new Promise<void>(async (resolve) => {
  148. await Promise.all([
  149. GM.setValue(`_uucfg-${this.id}`, await this.serializeData(this.defaultData, useEncoding)),
  150. GM.setValue(`_uucfgver-${this.id}`, this.formatVersion),
  151. GM.setValue(`_uucfgenc-${this.id}`, useEncoding),
  152. ]);
  153. resolve();
  154. });
  155. }
  156. /**
  157. * Call this method to clear all persistently stored data associated with this DataStore instance.
  158. * The in-memory cache will be left untouched, so you may still access the data with {@linkcode getData()}
  159. * Calling {@linkcode loadData()} or {@linkcode setData()} after this method was called will recreate persistent storage with the cached or default data.
  160. *
  161. * ⚠️ This requires the additional directive `@grant GM.deleteValue`
  162. */
  163. public async deleteData(): Promise<void> {
  164. await Promise.all([
  165. GM.deleteValue(`_uucfg-${this.id}`),
  166. GM.deleteValue(`_uucfgver-${this.id}`),
  167. GM.deleteValue(`_uucfgenc-${this.id}`),
  168. ]);
  169. }
  170. /**
  171. * Runs all necessary migration functions consecutively and saves the result to the in-memory cache and persistent storage and also returns it.
  172. * This method is automatically called by {@linkcode loadData()} if the data format has changed since the last time the data was saved.
  173. * 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.
  174. *
  175. * 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.
  176. */
  177. public async runMigrations(oldData: any, oldFmtVer: number, resetOnError = true): Promise<TData> {
  178. if(!this.migrations)
  179. return oldData as TData;
  180. let newData = oldData;
  181. const sortedMigrations = Object.entries(this.migrations)
  182. .sort(([a], [b]) => Number(a) - Number(b));
  183. let lastFmtVer = oldFmtVer;
  184. for(const [fmtVer, migrationFunc] of sortedMigrations) {
  185. const ver = Number(fmtVer);
  186. if(oldFmtVer < this.formatVersion && oldFmtVer < ver) {
  187. try {
  188. const migRes = migrationFunc(newData);
  189. newData = migRes instanceof Promise ? await migRes : migRes;
  190. lastFmtVer = oldFmtVer = ver;
  191. }
  192. catch(err) {
  193. if(!resetOnError)
  194. throw new Error(`Error while running migration function for format version '${fmtVer}'`);
  195. console.error(`Error while running migration function for format version '${fmtVer}' - resetting to the default value.`, err);
  196. await this.saveDefaultData();
  197. return this.getData();
  198. }
  199. }
  200. }
  201. await Promise.all([
  202. GM.setValue(`_uucfg-${this.id}`, await this.serializeData(newData)),
  203. GM.setValue(`_uucfgver-${this.id}`, lastFmtVer),
  204. GM.setValue(`_uucfgenc-${this.id}`, this.encodingEnabled()),
  205. ]);
  206. return this.cachedData = { ...newData as TData };
  207. }
  208. /** Returns whether encoding and decoding are enabled for this DataStore instance */
  209. public encodingEnabled(): this is Required<Pick<DataStoreOptions<TData>, "encodeData" | "decodeData">> {
  210. return Boolean(this.encodeData && this.decodeData);
  211. }
  212. /** Serializes the data using the optional this.encodeData() and returns it as a string */
  213. private async serializeData(data: TData, useEncoding = true): Promise<string> {
  214. const stringData = JSON.stringify(data);
  215. if(!this.encodeData || !this.decodeData || !useEncoding)
  216. return stringData;
  217. const encRes = this.encodeData(stringData);
  218. if(encRes instanceof Promise)
  219. return await encRes;
  220. return encRes;
  221. }
  222. /** Deserializes the data using the optional this.decodeData() and returns it as a JSON object */
  223. private async deserializeData(data: string, useEncoding = true): Promise<TData> {
  224. let decRes = this.encodingEnabled() && useEncoding ? this.decodeData(data) : undefined;
  225. if(decRes instanceof Promise)
  226. decRes = await decRes;
  227. return JSON.parse(decRes ?? data) as TData;
  228. }
  229. /** Copies a JSON-compatible object and loses its internal references */
  230. private deepCopy<T>(obj: T): T {
  231. return JSON.parse(JSON.stringify(obj));
  232. }
  233. }