DataStoreSerializer.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233
  1. /**
  2. * @module lib/DataStoreSerializer
  3. * This module contains the DataStoreSerializer class, which allows you to import and export serialized DataStore data - [see the documentation for more info](https://github.com/Sv443-Network/UserUtils/blob/main/docs.md#datastoreserializer)
  4. */
  5. import { computeHash } from "./crypto.js";
  6. import { getUnsafeWindow } from "./dom.js";
  7. import { ChecksumMismatchError } from "./errors.js";
  8. import type { DataStore } from "./DataStore.js";
  9. export type DataStoreSerializerOptions = {
  10. /** Whether to add a checksum to the exported data. Defaults to `true` */
  11. addChecksum?: boolean;
  12. /** Whether to ensure the integrity of the data when importing it by throwing an error (doesn't throw when the checksum property doesn't exist). Defaults to `true` */
  13. ensureIntegrity?: boolean;
  14. };
  15. /** Serialized data of a DataStore instance */
  16. export type SerializedDataStore = {
  17. /** The ID of the DataStore instance */
  18. id: string;
  19. /** The serialized data */
  20. data: string;
  21. /** The format version of the data */
  22. formatVersion: number;
  23. /** Whether the data is encoded */
  24. encoded: boolean;
  25. /** The checksum of the data - key is not present when `addChecksum` is `false` */
  26. checksum?: string;
  27. };
  28. /** Result of {@linkcode DataStoreSerializer.loadStoresData()} */
  29. export type LoadStoresDataResult = {
  30. /** The ID of the DataStore instance */
  31. id: string;
  32. /** The in-memory data object */
  33. data: object;
  34. }
  35. /** A filter for selecting data stores */
  36. export type StoreFilter = string[] | ((id: string) => boolean);
  37. /**
  38. * Allows for easy serialization and deserialization of multiple DataStore instances.
  39. *
  40. * 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.
  41. * Remember that you can call `super.methodName()` in the subclass to access the original method.
  42. *
  43. * - ⚠️ Needs to run in a secure context (HTTPS) due to the use of the SubtleCrypto API if checksumming is enabled.
  44. */
  45. export class DataStoreSerializer {
  46. protected stores: DataStore[];
  47. protected options: Required<DataStoreSerializerOptions>;
  48. constructor(stores: DataStore[], options: DataStoreSerializerOptions = {}) {
  49. if(!getUnsafeWindow().crypto || !getUnsafeWindow().crypto.subtle)
  50. throw new Error("DataStoreSerializer has to run in a secure context (HTTPS)!");
  51. this.stores = stores;
  52. this.options = {
  53. addChecksum: true,
  54. ensureIntegrity: true,
  55. ...options,
  56. };
  57. }
  58. /** Calculates the checksum of a string */
  59. protected async calcChecksum(input: string): Promise<string> {
  60. return computeHash(input, "SHA-256");
  61. }
  62. /**
  63. * Serializes only a subset of the data stores into a string.
  64. * @param stores An array of store IDs or functions that take a store ID and return a boolean
  65. * @param useEncoding Whether to encode the data using each DataStore's `encodeData()` method
  66. * @param stringified Whether to return the result as a string or as an array of `SerializedDataStore` objects
  67. */
  68. public async serializePartial(stores: StoreFilter, useEncoding?: boolean, stringified?: true): Promise<string>;
  69. /**
  70. * Serializes only a subset of the data stores into a string.
  71. * @param stores An array of store IDs or functions that take a store ID and return a boolean
  72. * @param useEncoding Whether to encode the data using each DataStore's `encodeData()` method
  73. * @param stringified Whether to return the result as a string or as an array of `SerializedDataStore` objects
  74. */
  75. public async serializePartial(stores: StoreFilter, useEncoding?: boolean, stringified?: false): Promise<SerializedDataStore[]>;
  76. /**
  77. * Serializes only a subset of the data stores into a string.
  78. * @param stores An array of store IDs or functions that take a store ID and return a boolean
  79. * @param useEncoding Whether to encode the data using each DataStore's `encodeData()` method
  80. * @param stringified Whether to return the result as a string or as an array of `SerializedDataStore` objects
  81. */
  82. public async serializePartial(stores: StoreFilter, useEncoding?: boolean, stringified?: boolean): Promise<string | SerializedDataStore[]>;
  83. /**
  84. * Serializes only a subset of the data stores into a string.
  85. * @param stores An array of store IDs or functions that take a store ID and return a boolean
  86. * @param useEncoding Whether to encode the data using each DataStore's `encodeData()` method
  87. * @param stringified Whether to return the result as a string or as an array of `SerializedDataStore` objects
  88. */
  89. public async serializePartial(stores: StoreFilter, useEncoding = true, stringified = true): Promise<string | SerializedDataStore[]> {
  90. const serData: SerializedDataStore[] = [];
  91. for(const storeInst of this.stores.filter(s => typeof stores === "function" ? stores(s.id) : stores.includes(s.id))) {
  92. const data = useEncoding && storeInst.encodingEnabled()
  93. ? await storeInst.encodeData(JSON.stringify(storeInst.getData()))
  94. : JSON.stringify(storeInst.getData());
  95. serData.push({
  96. id: storeInst.id,
  97. data,
  98. formatVersion: storeInst.formatVersion,
  99. encoded: useEncoding && storeInst.encodingEnabled(),
  100. checksum: this.options.addChecksum
  101. ? await this.calcChecksum(data)
  102. : undefined,
  103. });
  104. }
  105. return stringified ? JSON.stringify(serData) : serData;
  106. }
  107. /**
  108. * Serializes the data stores into a string.
  109. * @param useEncoding Whether to encode the data using each DataStore's `encodeData()` method
  110. * @param stringified Whether to return the result as a string or as an array of `SerializedDataStore` objects
  111. */
  112. public async serialize(useEncoding?: boolean, stringified?: true): Promise<string>;
  113. /**
  114. * Serializes the data stores into a string.
  115. * @param useEncoding Whether to encode the data using each DataStore's `encodeData()` method
  116. * @param stringified Whether to return the result as a string or as an array of `SerializedDataStore` objects
  117. */
  118. public async serialize(useEncoding?: boolean, stringified?: false): Promise<SerializedDataStore[]>;
  119. /**
  120. * Serializes the data stores into a string.
  121. * @param useEncoding Whether to encode the data using each DataStore's `encodeData()` method
  122. * @param stringified Whether to return the result as a string or as an array of `SerializedDataStore` objects
  123. */
  124. public async serialize(useEncoding = true, stringified = true): Promise<string | SerializedDataStore[]> {
  125. return this.serializePartial(this.stores.map(s => s.id), useEncoding, stringified);
  126. }
  127. /**
  128. * Deserializes the data exported via {@linkcode serialize()} and imports only a subset into the DataStore instances.
  129. * Also triggers the migration process if the data format has changed.
  130. */
  131. public async deserializePartial(stores: StoreFilter, data: string | SerializedDataStore[]): Promise<void> {
  132. const deserStores: SerializedDataStore[] = typeof data === "string" ? JSON.parse(data) : data;
  133. if(!Array.isArray(deserStores) || !deserStores.every(DataStoreSerializer.isSerializedDataStoreObj))
  134. throw new TypeError("Invalid serialized data format! Expected an array of SerializedDataStore objects.");
  135. for(const storeData of deserStores.filter(s => typeof stores === "function" ? stores(s.id) : stores.includes(s.id))) {
  136. const storeInst = this.stores.find(s => s.id === storeData.id);
  137. if(!storeInst)
  138. throw new Error(`DataStore instance with ID "${storeData.id}" not found! Make sure to provide it in the DataStoreSerializer constructor.`);
  139. if(this.options.ensureIntegrity && typeof storeData.checksum === "string") {
  140. const checksum = await this.calcChecksum(storeData.data);
  141. if(checksum !== storeData.checksum)
  142. throw new ChecksumMismatchError(`Checksum mismatch for DataStore with ID "${storeData.id}"!\nExpected: ${storeData.checksum}\nHas: ${checksum}`);
  143. }
  144. const decodedData = storeData.encoded && storeInst.encodingEnabled()
  145. ? await storeInst.decodeData(storeData.data)
  146. : storeData.data;
  147. if(storeData.formatVersion && !isNaN(Number(storeData.formatVersion)) && Number(storeData.formatVersion) < storeInst.formatVersion)
  148. await storeInst.runMigrations(JSON.parse(decodedData), Number(storeData.formatVersion), false);
  149. else
  150. await storeInst.setData(JSON.parse(decodedData));
  151. }
  152. }
  153. /**
  154. * Deserializes the data exported via {@linkcode serialize()} and imports the data into all matching DataStore instances.
  155. * Also triggers the migration process if the data format has changed.
  156. */
  157. public async deserialize(data: string | SerializedDataStore[]): Promise<void> {
  158. return this.deserializePartial(this.stores.map(s => s.id), data);
  159. }
  160. /**
  161. * Loads the persistent data of the DataStore instances into the in-memory cache.
  162. * Also triggers the migration process if the data format has changed.
  163. * @param stores An array of store IDs or a function that takes the store IDs and returns a boolean - if omitted, all stores will be loaded
  164. * @returns Returns a PromiseSettledResult array with the results of each DataStore instance in the format `{ id: string, data: object }`
  165. */
  166. public async loadStoresData(stores?: StoreFilter): Promise<PromiseSettledResult<LoadStoresDataResult>[]> {
  167. return Promise.allSettled(
  168. this.getStoresFiltered(stores)
  169. .map(async (store) => ({
  170. id: store.id,
  171. data: await store.loadData(),
  172. })),
  173. );
  174. }
  175. /**
  176. * Resets the persistent and in-memory data of the DataStore instances to their default values.
  177. * @param stores An array of store IDs or a function that takes the store IDs and returns a boolean - if omitted, all stores will be affected
  178. */
  179. public async resetStoresData(stores?: StoreFilter): Promise<PromiseSettledResult<void>[]> {
  180. return Promise.allSettled(
  181. this.getStoresFiltered(stores).map(store => store.saveDefaultData()),
  182. );
  183. }
  184. /**
  185. * Deletes the persistent data of the DataStore instances.
  186. * Leaves the in-memory data untouched.
  187. * @param stores An array of store IDs or a function that takes the store IDs and returns a boolean - if omitted, all stores will be affected
  188. */
  189. public async deleteStoresData(stores?: StoreFilter): Promise<PromiseSettledResult<void>[]> {
  190. return Promise.allSettled(
  191. this.getStoresFiltered(stores).map(store => store.deleteData()),
  192. );
  193. }
  194. /** Checks if a given value is an array of SerializedDataStore objects */
  195. public static isSerializedDataStoreObjArray(obj: unknown): obj is SerializedDataStore[] {
  196. return Array.isArray(obj) && obj.every((o) => typeof o === "object" && o !== null && "id" in o && "data" in o && "formatVersion" in o && "encoded" in o);
  197. }
  198. /** Checks if a given value is a SerializedDataStore object */
  199. public static isSerializedDataStoreObj(obj: unknown): obj is SerializedDataStore {
  200. return typeof obj === "object" && obj !== null && "id" in obj && "data" in obj && "formatVersion" in obj && "encoded" in obj;
  201. }
  202. /** Returns the DataStore instances whose IDs match the provided array or function */
  203. protected getStoresFiltered(stores?: StoreFilter): DataStore[] {
  204. return this.stores.filter(s => typeof stores === "undefined" ? true : Array.isArray(stores) ? stores.includes(s.id) : stores(s.id));
  205. }
  206. }