1
0

DataStoreSerializer.ts 7.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171
  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 */
  11. addChecksum?: boolean;
  12. /** Whether to ensure the integrity of the data when importing it (unless the checksum property doesn't exist) */
  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 for data without a checksum */
  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. /**
  36. * Allows for easy serialization and deserialization of multiple DataStore instances.
  37. *
  38. * 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.
  39. * Remember that you can call `super.methodName()` in the subclass to access the original method.
  40. *
  41. * - ⚠️ Needs to run in a secure context (HTTPS) due to the use of the SubtleCrypto API if checksumming is enabled.
  42. */
  43. export class DataStoreSerializer {
  44. protected stores: DataStore[];
  45. protected options: Required<DataStoreSerializerOptions>;
  46. constructor(stores: DataStore[], options: DataStoreSerializerOptions = {}) {
  47. if(!getUnsafeWindow().crypto || !getUnsafeWindow().crypto.subtle)
  48. throw new Error("DataStoreSerializer has to run in a secure context (HTTPS)!");
  49. this.stores = stores;
  50. this.options = {
  51. addChecksum: true,
  52. ensureIntegrity: true,
  53. ...options,
  54. };
  55. }
  56. /** Calculates the checksum of a string */
  57. protected async calcChecksum(input: string): Promise<string> {
  58. return computeHash(input, "SHA-256");
  59. }
  60. /**
  61. * Serializes the data stores into a string.
  62. * @param useEncoding Whether to encode the data using each DataStore's `encodeData()` method
  63. * @param stringified Whether to return the result as a string or as an array of `SerializedDataStore` objects
  64. */
  65. public async serialize(useEncoding?: boolean, stringified?: true): Promise<string>;
  66. /**
  67. * Serializes the data stores into a string.
  68. * @param useEncoding Whether to encode the data using each DataStore's `encodeData()` method
  69. * @param stringified Whether to return the result as a string or as an array of `SerializedDataStore` objects
  70. */
  71. public async serialize(useEncoding?: boolean, stringified?: false): Promise<SerializedDataStore[]>;
  72. /**
  73. * Serializes the data stores into a string.
  74. * @param useEncoding Whether to encode the data using each DataStore's `encodeData()` method
  75. * @param stringified Whether to return the result as a string or as an array of `SerializedDataStore` objects
  76. */
  77. public async serialize(useEncoding = true, stringified = true): Promise<string | SerializedDataStore[]> {
  78. const serData: SerializedDataStore[] = [];
  79. for(const storeInst of this.stores) {
  80. const data = useEncoding && storeInst.encodingEnabled()
  81. ? await storeInst.encodeData(JSON.stringify(storeInst.getData()))
  82. : JSON.stringify(storeInst.getData());
  83. serData.push({
  84. id: storeInst.id,
  85. data,
  86. formatVersion: storeInst.formatVersion,
  87. encoded: useEncoding && storeInst.encodingEnabled(),
  88. checksum: this.options.addChecksum
  89. ? await this.calcChecksum(data)
  90. : undefined,
  91. });
  92. }
  93. return stringified ? JSON.stringify(serData) : serData;
  94. }
  95. /**
  96. * Deserializes the data exported via {@linkcode serialize()} and imports it into the DataStore instances.
  97. * Also triggers the migration process if the data format has changed.
  98. */
  99. public async deserialize(serializedData: string | SerializedDataStore[]): Promise<void> {
  100. const deserStores: SerializedDataStore[] = typeof serializedData === "string" ? JSON.parse(serializedData) : serializedData;
  101. if(!Array.isArray(deserStores) || !deserStores.every(DataStoreSerializer.isSerializedDataStore))
  102. throw new TypeError("Invalid serialized data format! Expected an array of SerializedDataStore objects.");
  103. for(const storeData of deserStores) {
  104. const storeInst = this.stores.find(s => s.id === storeData.id);
  105. if(!storeInst)
  106. throw new Error(`DataStore instance with ID "${storeData.id}" not found! Make sure to provide it in the DataStoreSerializer constructor.`);
  107. if(this.options.ensureIntegrity && typeof storeData.checksum === "string") {
  108. const checksum = await this.calcChecksum(storeData.data);
  109. if(checksum !== storeData.checksum)
  110. throw new ChecksumMismatchError(`Checksum mismatch for DataStore with ID "${storeData.id}"!\nExpected: ${storeData.checksum}\nHas: ${checksum}`);
  111. }
  112. const decodedData = storeData.encoded && storeInst.encodingEnabled()
  113. ? await storeInst.decodeData(storeData.data)
  114. : storeData.data;
  115. if(storeData.formatVersion && !isNaN(Number(storeData.formatVersion)) && Number(storeData.formatVersion) < storeInst.formatVersion)
  116. await storeInst.runMigrations(JSON.parse(decodedData), Number(storeData.formatVersion), false);
  117. else
  118. await storeInst.setData(JSON.parse(decodedData));
  119. }
  120. }
  121. /**
  122. * Loads the persistent data of the DataStore instances into the in-memory cache.
  123. * Also triggers the migration process if the data format has changed.
  124. * @returns Returns a PromiseSettledResult array with the results of each DataStore instance in the format `{ id: string, data: object }`
  125. */
  126. public async loadStoresData(): Promise<PromiseSettledResult<LoadStoresDataResult>[]> {
  127. return Promise.allSettled(this.stores.map(
  128. async store => ({
  129. id: store.id,
  130. data: await store.loadData(),
  131. })
  132. ));
  133. }
  134. /** Resets the persistent data of the DataStore instances to their default values. */
  135. public async resetStoresData(): Promise<PromiseSettledResult<void>[]> {
  136. return Promise.allSettled(this.stores.map(store => store.saveDefaultData()));
  137. }
  138. /**
  139. * Deletes the persistent data of the DataStore instances.
  140. * Leaves the in-memory data untouched.
  141. */
  142. public async deleteStoresData(): Promise<PromiseSettledResult<void>[]> {
  143. return Promise.allSettled(this.stores.map(store => store.deleteData()));
  144. }
  145. /** Checks if a given value is a SerializedDataStore object */
  146. public static isSerializedDataStore(obj: unknown): obj is SerializedDataStore {
  147. return typeof obj === "object" && obj !== null && "id" in obj && "data" in obj && "formatVersion" in obj && "encoded" in obj;
  148. }
  149. }