]> Lady’s Gitweb - Etiquette/blobdiff - memory.js
Initial {memory ∣ storage} model
[Etiquette] / memory.js
diff --git a/memory.js b/memory.js
new file mode 100644 (file)
index 0000000..6773bdd
--- /dev/null
+++ b/memory.js
@@ -0,0 +1,361 @@
+// 📧🏷️ Étiquette ∷ memory.js
+// ====================================================================
+//
+// Copyright © 2023 Lady [@ Lady’s Computer].
+//
+// This Source Code Form is subject to the terms of the Mozilla Public
+// License, v. 2.0. If a copy of the MPL was not distributed with this
+// file, You can obtain one at <https://mozilla.org/MPL/2.0/>.
+
+import { wrmgBase32Binary, wrmgBase32String } from "./deps.js";
+
+/**
+ * A symbol which is used internally to identify the constructor
+ * associated with a stored object.
+ *
+ * ※ This value is not exposed.
+ */
+const constructorSymbol = Symbol("constructor");
+
+/**
+ * Mints a new W·R·M·G base32 identifier with a prefixed checksum.
+ *
+ * If an argument is provided, it is used as the underlying numeric
+ * value for the identifier.
+ *
+ * The return value is a `String` *object* with a `.value` own property
+ * giving the underlying numeric value for the string.
+ *
+ * ※ This function is not exposed.
+ */
+const mintID = ($ = null) => {
+  const [number, buffer] = $ == null
+    ? (() => {
+      // No value was provided; generate a random one and store its
+      // buffer.
+      const values = crypto.getRandomValues(new Uint32Array(1));
+      const view = new DataView(values.buffer);
+      return [
+        view.getUint32(0) >>> 2, // drop the final 2 bits
+        view.buffer,
+      ];
+    })()
+    : [$ >>> 0 & -1 >>> 2, null];
+  const checksum = number % 37;
+  const wrmg = wrmgBase32String(
+    buffer ?? (() => {
+      // A value was provided, so a buffer still needs to be generated.
+      const view = new DataView(new ArrayBuffer(4));
+      view.setUint32(0, number << 2, false);
+      return view.buffer;
+    })(),
+  );
+  return Object.assign(
+    new String(
+      `${"0123456789ABCDEFGHJKMNPQRSTVWXYZ*~$=U"[checksum]}${
+        wrmg.substring(0, 2)
+      }-${wrmg.substring(2, 6)}`,
+    ),
+    { value: number },
+  );
+};
+
+/**
+ * Validates the checksum prefixing the provided W·R·M·G base32
+ * identifier and then returns that same identifier in normalized form.
+ *
+ * ※ This function is not exposed.
+ */
+const normalizeID = ($) => {
+  const identifier = `${$}`;
+  const checksum = "0123456789ABCDEFGHJKMNPQRSTVWXYZ*~$=U".indexOf(
+    identifier[0].toUpperCase(),
+  );
+  if (checksum == -1) {
+    // The checksum character is invalid.
+    throw new RangeError(`Invalid checksum: "${identifier[0]}".`);
+  } else {
+    // There is a valid checksum.
+    const binary = wrmgBase32Binary`${identifier.substring(1)}0`;
+    const { byteLength } = binary;
+    if (byteLength != 4) {
+      // The identifier was of unexpected size.
+      throw new RangeError(
+        `Expected id to fit within 4 bytes, but got ${byteLength}.`,
+      );
+    } else {
+      // The identifier was correctly‐sized.
+      const value = new DataView(binary).getUint32(0, false);
+      if (value & 0b11) {
+        // The final two bits, which should have been padded with
+        // zeroes, have a nonzero value.
+        //
+        // This should be impossible and indicates something went very
+        // wrong in base32 decoding.
+        throw new RangeError("Unexpected values in lower two bits");
+      } else {
+        // The final two bits are zero as expected.
+        const number = value >>> 2;
+        if (checksum != number % 37) {
+          // The checksum does not match the number.
+          throw new RangeError(
+            `Invalid checksum for id: ${identifier} (${number})`,
+          );
+        } else {
+          // The checksum matches. Mint a new identifier with the same
+          // number to normalize its form.
+          return `${mintID(number)}`;
+        }
+      }
+    }
+  }
+};
+
+/**
+ * A symbol which is used to identify the method for constructing a new
+ * instance of a constructor based on stored data.
+ *
+ * ※ This value is exposed as `Storage.toInstance`.
+ */
+const toInstanceSymbol = Symbol("Storage.toInstance");
+
+/**
+ * A symbol which is used to identify the method for converting an
+ * instance into an object of enumerable own properties suitable for
+ * persistence.
+ *
+ * ※ This value is exposed as `Storage.toObject`.
+ */
+const toObjectSymbol = Symbol("Storage.toObject");
+
+/**
+ * An in‐memory store of items with checksum‐prefixed 6‐digit W·R·M·G
+ * base32 identifiers.
+ */
+export class Storage {
+  static {
+    // Define `Storage.toInstance` and `Storage.toObject` as
+    // nonconfigurable, non·enumerable, read·only properties with the
+    // appropriate values.
+    Object.defineProperties(this, {
+      toInstance: {
+        configurable: false,
+        enumerable: false,
+        value: toInstanceSymbol,
+        writable: false,
+      },
+      toObject: {
+        configurable: false,
+        enumerable: false,
+        value: toObjectSymbol,
+        writable: false,
+      },
+    });
+  }
+
+  /**
+   * A `Set` of deleted identifiers, to ensure they are not
+   * re·assigned.
+   */
+  #deleted = new Set();
+
+  /** The `Map` used to actually store the data internally. */
+  #store = new Map();
+
+  /**
+   * Returns a new instance constructed from the provided data as
+   * retrieved from the provided identifier.
+   */
+  #construct(data, id) {
+    const {
+      [constructorSymbol]: constructor,
+      ...object
+    } = data;
+    if (!(toInstanceSymbol in constructor)) {
+      // There is no method on the constructor for generating an
+      // instance.
+      throw new TypeError(
+        "Constructor must implement Storage.toInstance for object to be retrieved.",
+      );
+    } else {
+      // Generate an instance and return it.
+      return constructor[toInstanceSymbol](object, id);
+    }
+  }
+
+  /**
+   * Stores the provided instance and returns its identifier.
+   *
+   * If a second argument is given, that identifier will be used;
+   * otherwise, an unused one will be allocated.
+   */
+  #persist(instance, maybeID = null) {
+    const store = this.#store;
+    const deleted = this.#deleted;
+    if (Object(instance) !== instance) {
+      // The provided value is not an object.
+      throw new TypeError("Only objects can be stored.");
+    } else if (!(toObjectSymbol in instance)) {
+      // The provided value does not have a method for generating an
+      // object to store.
+      throw new TypeError(
+        "Object must implement Storage.toObject to be stored.",
+      );
+    } else {
+      // The provided value has a method for generating a storage
+      // object.
+      const { constructor } = instance;
+      const object = instance[toObjectSymbol]();
+      const id = maybeID ?? (() => {
+        // No identifier was provided; attempt to generate one.
+        if (deleted.size + store.size < 1 << 30) {
+          // There is at least one available identifier.
+          let id = mintID();
+          let literalValue = `${id}`;
+          while (
+            deleted.has(literalValue) || store.has(literalValue)
+          ) {
+            // Generate successive identifiers until an available one
+            // is reached.
+            id = mintID(id.value + 1);
+            literalValue = `${id}`;
+          }
+          return literalValue;
+        } else {
+          // There are no identifiers left to assign.
+          throw new TypeError("Out of room.");
+        }
+      })();
+      store.set(
+        id,
+        Object.assign(
+          Object.create(null, {
+            [constructorSymbol]: {
+              configurable: false,
+              enumerable: false,
+              value: constructor,
+              writable: false,
+            },
+          }),
+          object,
+        ),
+      );
+      return id;
+    }
+  }
+
+  /**
+   * Stores the provided object with a new identifier, and returns the
+   * assigned identifier.
+   */
+  add(instance) {
+    return this.#persist(instance);
+  }
+
+  /**
+   * Removes the data at the provided identifier.
+   *
+   * The identifier will not be re·assigned.
+   */
+  delete(id) {
+    const store = this.#store;
+    const validID = normalizeID(id);
+    this.#deleted.add(validID);
+    return store.delete(validID);
+  }
+
+  /** Yields successive identifier~instance pairs from storage. */
+  *entries() {
+    for (const [id, data] of this.#store.entries()) {
+      // Iterate over the entries, construct instances, and yield them
+      // with their identifiers.
+      yield [id, this.#construct(data, id)];
+    }
+  }
+
+  /**
+   * Call the provided callback function with successive instances
+   * constructed from data in storage.
+   *
+   * The callback function will be called with the constructed
+   * instance, its identifier, and this `Storage` instance.
+   *
+   * If a second argument is provided, it will be used as the `this`
+   * value.
+   */
+  forEach(callback, thisArg = undefined) {
+    for (const [id, data] of this.#store.entries()) {
+      // Iterate over the entries, construct objects, and call the
+      // callback function with them and their identifiers.
+      callback.call(thisArg, this.#construct(data, id), id, this);
+    }
+  }
+
+  /**
+   * Returns an instance constructed from the data stored at the
+   * provided identifier, or `null` if the identifier has no data.
+   */
+  get(id) {
+    const store = this.#store;
+    const validID = normalizeID(id);
+    const data = store.get(validID);
+    if (data == null) {
+      // No object was at the provided identifier.
+      return null;
+    } else {
+      // The provided identifier had a stored object; return the
+      // constructed instance.
+      return this.#construct(data, validID);
+    }
+  }
+
+  /**
+   * Returns whether the provided identifier currently has data
+   * associated with it.
+   */
+  has(id) {
+    const store = this.#store;
+    return store.has(normalizeID(id));
+  }
+
+  /** Yields successive identifiers with data in storage. */
+  *keys() {
+    yield* this.#store.keys();
+  }
+
+  /**
+   * Sets the data for the provided identifier to be that generated
+   * from the provided instance, then returns this `Storage` object.
+   */
+  set(id, instance) {
+    this.#persist(instance, normalizeID(id));
+    return this;
+  }
+
+  /**
+   * Returns the number of identifiers with data in storage.
+   *
+   * ☡ This number may be smaller than the actual number of used
+   * identifiers, as deleted identifiers are *not* freed up for re·use.
+   */
+  get size() {
+    return this.#store.size;
+  }
+
+  /** Yields successive instances constructed from data in storage. */
+  *values() {
+    for (const [id, data] of this.#store.entries()) {
+      // Iterate over the entries, construct instances, and yield them.
+      yield this.#construct(data, id);
+    }
+  }
+
+  /** Yields successive identifier~instance pairs from storage. */
+  *[Symbol.iterator]() {
+    for (const [id, data] of this.#store.entries()) {
+      // Iterate over the entries, construct instances, and yield them
+      // with their identifiers.
+      yield [id, this.#construct(data, id)];
+    }
+  }
+}
This page took 0.242161 seconds and 4 git commands to generate.