Kaynağa Gözat

feat: DataStore improvements

Sven 9 ay önce
ebeveyn
işleme
948ac899dd
3 değiştirilmiş dosya ile 50 ekleme ve 16 silme
  1. 5 0
      .changeset/short-socks-travel.md
  2. 8 0
      README.md
  3. 37 16
      lib/DataStore.ts

+ 5 - 0
.changeset/short-socks-travel.md

@@ -0,0 +1,5 @@
+---
+"@sv443-network/userutils": minor
+---
+
+DataStore: made `runMigrations`, `encodeData` and `decodeData` public and added `encodingEnabled` method

+ 8 - 0
README.md

@@ -1007,6 +1007,14 @@ Fully deletes the data from persistent storage.
 The internal cache will be left untouched, so any subsequent calls to `getData()` will return the data that was last loaded.  
 If `loadData()` or `setData()` are called after this, the persistent storage will be populated with the value of `options.defaultData` again.  
 ⚠️ If you want to use this method, the additional directive `@grant GM.deleteValue` is required.  
+  
+`runMigrations(oldData: any, oldFmtVer: number, resetOnError?: boolean): Promise<TData>`  
+Runs all necessary migration functions to migrate the given `oldData` to the latest format.  
+If `resetOnError` is set to `false`, the migration will be aborted if an error is thrown and no data will be committed. If it is set to `true` (default) and an error is encountered, it will be suppressed and the `defaultData` will be saved to persistent storage and returned.  
+  
+`encodingEnabled(): boolean`  
+Returns `true` if both `options.encodeData` and `options.decodeData` are set, else `false`.  
+Uses TypeScript's type guard notation for easier use in conditional statements.
 
 <br>
 

+ 37 - 16
lib/DataStore.ts

@@ -55,22 +55,22 @@ export type DataStoreOptions<TData> = {
 });
 
 /**
- * Manages a sync & async persistent JSON database that is cached in memory and persistently saved across sessions.  
+ * 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)  
  * Supports migrating data from older format versions to newer ones and populating the cache with default data if no persistent data is found.  
  *   
  * ⚠️ Requires the directives `@grant GM.getValue` and `@grant GM.setValue`  
  * ⚠️ Make sure to call {@linkcode loadData()} at least once after creating an instance, or the returned data will be the same as `options.defaultData`
  * 
- * @template TData The type of the data that is saved in persistent storage (will be automatically inferred from `defaultData`) - this should also be the type of the data format associated with the current `formatVersion`
+ * @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.)
  */
-export class DataStore<TData = any> {
+export class DataStore<TData extends object = object> {
   public readonly id: string;
   public readonly formatVersion: number;
   public readonly defaultData: TData;
+  public readonly encodeData: DataStoreOptions<TData>["encodeData"];
+  public readonly decodeData: DataStoreOptions<TData>["decodeData"];
   private cachedData: TData;
   private migrations?: DataMigrationsDict;
-  private encodeData: DataStoreOptions<TData>["encodeData"];
-  private decodeData: DataStoreOptions<TData>["decodeData"];
 
   /**
    * Creates an instance of DataStore to manage a sync & async database that is cached in memory and persistently saved across sessions.  
@@ -81,7 +81,7 @@ export class DataStore<TData = any> {
    * 
    * @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`
    * @param options The options for this DataStore instance
-  */
+   */
   constructor(options: DataStoreOptions<TData>) {
     this.id = options.id;
     this.formatVersion = options.formatVersion;
@@ -100,7 +100,7 @@ export class DataStore<TData = any> {
   public async loadData(): Promise<TData> {
     try {
       const gmData = await GM.getValue(`_uucfg-${this.id}`, this.defaultData);
-      let gmFmtVer = Number(await GM.getValue(`_uucfgver-${this.id}`));
+      let gmFmtVer = Number(await GM.getValue(`_uucfgver-${this.id}`, NaN));
 
       if(typeof gmData !== "string") {
         await this.saveDefaultData();
@@ -109,15 +109,22 @@ export class DataStore<TData = any> {
 
       const isEncoded = await GM.getValue(`_uucfgenc-${this.id}`, false);
 
-      if(isNaN(gmFmtVer))
+      let saveData = false;
+      if(isNaN(gmFmtVer)) {
         await GM.setValue(`_uucfgver-${this.id}`, gmFmtVer = this.formatVersion);
+        saveData = true;
+      }
 
       let parsed = await this.deserializeData(gmData, isEncoded);
 
       if(gmFmtVer < this.formatVersion && this.migrations)
         parsed = await this.runMigrations(parsed, gmFmtVer);
 
-      return this.cachedData = { ...parsed };
+      if(saveData)
+        await this.setData(parsed);
+
+      this.cachedData = { ...parsed };
+      return this.cachedData;
     }
     catch(err) {
       console.warn("Error while parsing JSON data, resetting it to the default value.", err);
@@ -137,7 +144,7 @@ export class DataStore<TData = any> {
   /** Saves the data synchronously to the in-memory cache and asynchronously to the persistent storage */
   public setData(data: TData): Promise<void> {
     this.cachedData = data;
-    const useEncoding = Boolean(this.encodeData && this.decodeData);
+    const useEncoding = this.encodingEnabled();
     return new Promise<void>(async (resolve) => {
       await Promise.all([
         GM.setValue(`_uucfg-${this.id}`, await this.serializeData(data, useEncoding)),
@@ -151,7 +158,7 @@ export class DataStore<TData = any> {
   /** Saves the default data passed in the constructor synchronously to the in-memory cache and asynchronously to persistent storage */
   public async saveDefaultData(): Promise<void> {
     this.cachedData = this.defaultData;
-    const useEncoding = Boolean(this.encodeData && this.decodeData);
+    const useEncoding = this.encodingEnabled();
     return new Promise<void>(async (resolve) => {
       await Promise.all([
         GM.setValue(`_uucfg-${this.id}`, await this.serializeData(this.defaultData, useEncoding)),
@@ -177,8 +184,14 @@ export class DataStore<TData = any> {
     ]);
   }
 
-  /** Runs all necessary migration functions consecutively - may be overwritten in a subclass */
-  protected async runMigrations(oldData: any, oldFmtVer: number): Promise<TData> {
+  /**
+   * Runs all necessary migration functions consecutively and saves the result to the in-memory cache and persistent storage and also returns it.  
+   * This method is automatically called by {@linkcode loadData()} if the data format has changed since the last time the data was saved.  
+   * 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.  
+   *   
+   * 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.
+   */
+  public async runMigrations(oldData: any, oldFmtVer: number, resetOnError = true): Promise<TData> {
     if(!this.migrations)
       return oldData as TData;
 
@@ -197,6 +210,9 @@ export class DataStore<TData = any> {
           lastFmtVer = oldFmtVer = ver;
         }
         catch(err) {
+          if(!resetOnError)
+            throw new Error(`Error while running migration function for format version '${fmtVer}'`);
+
           console.error(`Error while running migration function for format version '${fmtVer}' - resetting to the default value.`, err);
 
           await this.saveDefaultData();
@@ -208,10 +224,15 @@ export class DataStore<TData = any> {
     await Promise.all([
       GM.setValue(`_uucfg-${this.id}`, await this.serializeData(newData)),
       GM.setValue(`_uucfgver-${this.id}`, lastFmtVer),
-      GM.setValue(`_uucfgenc-${this.id}`, Boolean(this.encodeData && this.decodeData)),
+      GM.setValue(`_uucfgenc-${this.id}`, this.encodingEnabled()),
     ]);
 
-    return newData as TData;
+    return this.cachedData = { ...newData as TData };
+  }
+
+  /** Returns whether encoding and decoding are enabled for this DataStore instance */
+  public encodingEnabled(): this is Required<Pick<DataStoreOptions<TData>, "encodeData" | "decodeData">> {
+    return Boolean(this.encodeData && this.decodeData);
   }
 
   /** Serializes the data using the optional this.encodeData() and returns it as a string */
@@ -228,7 +249,7 @@ export class DataStore<TData = any> {
 
   /** Deserializes the data using the optional this.decodeData() and returns it as a JSON object */
   private async deserializeData(data: string, useEncoding = true): Promise<TData> {
-    let decRes = this.decodeData && this.encodeData && useEncoding ? this.decodeData(data) : undefined;
+    let decRes = this.encodingEnabled() && useEncoding ? this.decodeData(data) : undefined;
     if(decRes instanceof Promise)
       decRes = await decRes;