]> Lady’s Gitweb - Etiquette/commitdiff
Initial Tag model
authorLady <redacted>
Mon, 29 May 2023 05:18:55 +0000 (22:18 -0700)
committerLady <redacted>
Wed, 14 Jun 2023 02:07:18 +0000 (19:07 -0700)
Work is still needed here to allow `TagSystem`s to replay the
activities generated by persisting tags, but the `Tag` part of this is
close and working.

dev-deps.js
model.js [new file with mode: 0644]
model.test.js [new file with mode: 0644]
names.js [new file with mode: 0644]

index ed20df8187594e7532b0f8c1d635f29d08cd2927..4710b493edb5370e8bc259c9ec683d6d0169956a 100644 (file)
@@ -9,7 +9,10 @@
 
 export {
   assert,
+  assertArrayIncludes,
   assertEquals,
+  assertFalse,
+  assertObjectMatch,
   assertStrictEquals,
   assertThrows,
 } from "https://deno.land/std@0.189.0/testing/asserts.ts";
diff --git a/model.js b/model.js
new file mode 100644 (file)
index 0000000..9f7962d
--- /dev/null
+++ b/model.js
@@ -0,0 +1,1689 @@
+// 📧🏷️ Étiquette ∷ model.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 { Storage } from "./memory.js";
+import { taggingDiscoveryContext } from "./names.js";
+
+// TODO: Move these somewhere else and allow for modification before
+// they are used, freezing them only once tags actually start being
+// constructed (probably on first call to the `TagSystem` initializer
+// for convenience).
+//
+// Or else make them properties of the tag system itself and ∼fully
+// modifiable.
+
+/**
+ * Tag kinds which denote entity tags.
+ *
+ * ※ This object is not exposed.
+ */
+const ENTITY_TAG_KINDS = new Set([
+  "EntityTag",
+  "CharacterTag",
+  "InanimateEntityTag",
+]);
+
+/**
+ * Tag kinds which denote relationship tags.
+ *
+ * ※ This object is not exposed.
+ */
+const RELATIONSHIP_TAG_KINDS = new Set([
+  "RelationshipTag",
+  "FamilialRelationship Tag",
+  "FriendshipTag",
+  "RivalryTag",
+  "RomanticRelationshipTag",
+  "SexualRelationshipTag",
+]);
+
+/**
+ * Tag kinds which denote setting tags.
+ *
+ * ※ This object is not exposed.
+ */
+const SETTING_TAG_KINDS = new Set([
+  "SettingTag",
+  "LocationTag",
+  "TimePeriodTag",
+  "UniverseTag",
+]);
+
+/**
+ * Tag kinds which denote conceptual tags.
+ *
+ * ※ This object is not exposed.
+ */
+const CONCEPTUAL_TAG_KINDS = new Set(function* () {
+  yield "ConceptualTag";
+  yield* RELATIONSHIP_TAG_KINDS;
+}());
+
+/**
+ * All recognized tag kinds.
+ *
+ * ※ This object is not exposed.
+ */
+const TAG_KINDS = new Set(function* () {
+  yield "Tag";
+  yield "CanonTag";
+  yield* CONCEPTUAL_TAG_KINDS;
+  yield* ENTITY_TAG_KINDS;
+  yield "GenreTag";
+  yield* SETTING_TAG_KINDS;
+}());
+
+/**
+ * Tag kinds which can be in canon.
+ *
+ * ※ This object is not exposed.
+ */
+const HAS_IN_CANON = new Set(function* () {
+  yield* ENTITY_TAG_KINDS;
+  yield* SETTING_TAG_KINDS;
+}());
+
+/**
+ * Tag kinds which can be involved in relationship tags.
+ *
+ * ※ This object is not exposed.
+ */
+const INVOLVABLE_IN_RELATIONSHIP = new Set(function* () {
+  yield "CharacterTag";
+  yield* RELATIONSHIP_TAG_KINDS;
+}());
+
+/**
+ * Properties which take literal values instead of identifiers.
+ *
+ * These are the label terms.
+ */
+const LITERAL_TERMS = new Set([
+  "prefLabel",
+  "altLabel",
+  "hiddenLabel",
+]);
+
+/**
+ * Properties to skip when diffing.
+ *
+ * These are all inverses of properties included in diffs and cannot be
+ * changed manually.
+ */
+const SKIP_IN_DIFF = new Set([
+  "hasInCanon",
+  "isIncludedIn",
+  "narrower",
+]);
+
+/**
+ * A tag.
+ *
+ * `Tag`s are not assigned identifiers and do not have side·effects on
+ * other tags in the `TagSystem` until they are persisted with
+ * `::persist`, at which point changes to their relationships are
+ * applied.
+ *
+ * `Tag`s are also not kept up‐to‐date, but persisting an outdated
+ * `Tag` will *not* undo subsequent changes.
+ *
+ * ※ This class is not itself directly exposed, although bound
+ * versions of it are via `TagSystem::Tag`.
+ */
+class Tag {
+  /** The `TagSystem` this `Tag` belongs to. */
+  #system;
+
+  /** The `Storage` managed by this `Tag`’s `TagSystem`. */
+  #storage;
+
+  /**
+   * The 30‐bit W·R·M·G base32 identifier with leading checksum which
+   * has been assigned to this `Tag`.
+   *
+   * Will be `null` if this `Tag` has not been persisted. Otherwise,
+   * the format is `cxx-xxxx` (`c` = checksum; `x` = digit).
+   */
+  #identifier = null;
+
+  /** The kind of this `Tag`. */
+  #kind = "Tag";
+
+  /**
+   * The data which was attached to this `Tag` the last time it was
+   * persisted or retrieved from storage.
+   *
+   * Diffing with this will reveal changes.
+   */
+  #persistedData = null;
+
+  /** The current (modified) data associated with this `Tag`. */
+  #data = tagData();
+
+  /**
+   * Returns whether or not the provided value is a tag which shares a
+   * storage with this tag.
+   *
+   * Sharing a storage also implies sharing a `TagSystem`.
+   */
+  #isTagInStorage($) {
+    try {
+      // Try to compare the provided value’s internal store with
+      // the provided storage.
+      return $.#storage == this.#storage;
+    } catch {
+      // The provided value was not a `Tag`.
+      return false;
+    }
+  }
+
+  /**
+   * Constructs a new `Tag` of the provided kind and with the provided
+   * preferred label.
+   *
+   * ※ The first two arguments of this constructor are bound when
+   * generating the value of `TagSystem::Tag`. It isn’t possible to
+   * access this constructor in its unbound form from outside this
+   * module.
+   *
+   * ☡ This constructor throws if the provided kind is not supported.
+   */
+  constructor(system, storage, kind = "Tag", prefLabel = "") {
+    const kindString = `${kind}`;
+    if (TAG_KINDS.has(kindString)) {
+      // The provided kind is one of the recognized tag kinds.
+      this.#system = system;
+      this.#storage = storage;
+      this.#kind = kindString;
+      this.#data.prefLabel = prefLabel;
+    } else {
+      // The provided kind is not supported.
+      throw new RangeError(
+        `Cannot construct Tag: Unrecognized kind: ${kind}.`,
+      );
+    }
+  }
+
+  /**
+   * Yields the tags in the `TagSystem` associated with this
+   * constructor.
+   *
+   * ※ The first two arguments of this function are bound when
+   * generating the value of `TagSystem::Tag`. It isn’t possible to
+   * access this function in its unbound form from outside this module.
+   */
+  static *all(system, storage) {
+    for (const instance of storage.values()) {
+      // Iterate over the entries and yield the ones which are `Tag`s
+      // in this `TagSystem`.
+      if (Tag.getSystem(instance) == system) {
+        // The current instance is a `Tag` in this `TagSystem`.
+        yield instance;
+      } else {
+        // The current instance is not a `Tag` in this `TagSystem`.
+        /* do nothing */
+      }
+    }
+  }
+
+  /**
+   * Returns a new `Tag` resolved from the provided I·R·I.
+   *
+   * ※ The first two arguments of this function are bound when
+   * generating the value of `TagSystem::Tag`. It isn’t possible to
+   * access this function in its unbound form from outside this module.
+   *
+   * ※ If the I·R·I is not recognized, this function returns `null`.
+   */
+  static fromIRI(system, storage, iri) {
+    const name = `${iri}`;
+    const prefix =
+      `https://${system.authorityName}/tag:${system.taggingEntity}:`;
+    if (!name.startsWith(prefix)) {
+      // The I·R·I does not begin with the expected prefix.
+      return null;
+    } else {
+      // The I·R·I begins with the expected prefix.
+      const identifier = name.substring(prefix.length);
+      try {
+        // Attempt to resolve the identifier.
+        const instance = storage.get(identifier);
+        return Tag.getSystem(instance) == system ? instance : null;
+      } catch {
+        // Do not throw for bad identifiers.
+        return null;
+      }
+    }
+  }
+
+  /**
+   * Returns a new `Tag` resolved from the provided identifier.
+   *
+   * ※ The first two arguments of this function are bound when
+   * generating the value of `TagSystem::Tag`. It isn’t possible to
+   * access this function in its unbound form from outside this module.
+   *
+   * ☡ This function throws if the identifier is invalid.
+   *
+   * ※ If the identifier is valid but not recognized, this function
+   * returns `null`.
+   */
+  static fromIdentifier(system, storage, identifier) {
+    const instance = storage.get(identifier);
+    return Tag.getSystem(instance) == system ? instance : null;
+  }
+
+  /**
+   * Returns a new `Tag` resolved from the provided Tag U·R·I.
+   *
+   * ※ The first two arguments of this function are bound when
+   * generating the value of `TagSystem::Tag`. It isn’t possible to
+   * access this function in its unbound form from outside this module.
+   *
+   * ☡ This function throws if the provided Tag U·R·I does not match
+   * the tagging entity of this constructor’s `TagSystem`.
+   *
+   * ※ If the specific component of the Tag U·R·I is not recognized,
+   * this function returns `null`.
+   */
+  static fromTagURI(system, storage, tagURI) {
+    const tagName = `${tagURI}`;
+    const tagPrefix = `tag:${system.taggingEntity}:`;
+    if (!tagName.startsWith(tagPrefix)) {
+      // The Tag U·R·I does not begin with the expected prefix.
+      throw new RangeError(
+        `Tag U·R·I did not begin with the expected prefix: ${tagName}`,
+      );
+    } else {
+      // The I·R·I begins with the expected prefix.
+      const identifier = tagName.substring(tagPrefix.length);
+      try {
+        // Attempt to resolve the identifier.
+        const instance = storage.get(identifier);
+        return Tag.getSystem(instance) == system ? instance : null;
+      } catch {
+        // Do not throw for bad identifiers.
+        return null;
+      }
+    }
+  }
+
+  /**
+   * Returns the `TagSystem` that the provided value belongs to.
+   *
+   * ※ This function can be used to check if the provided value has
+   * private tag features.
+   *
+   * ※ This function is not exposed.
+   */
+  static getSystem($) {
+    return !(#system in Object($)) ? null : $.#system;
+  }
+
+  /**
+   * Yields the tag identifiers in the `TagSystem` associated with this
+   * constructor.
+   *
+   * ※ The first two arguments of this function are bound when
+   * generating the value of `TagSystem::Tag`. It isn’t possible to
+   * access this function in its unbound form from outside this module.
+   */
+  static *identifiers(system, storage) {
+    for (const [identifier, instance] of storage.entries()) {
+      // Iterate over the entries and yield the ones which are `Tag`s
+      // in this `TagSystem`.
+      if (Tag.getSystem(instance) == system) {
+        // The current instance is a `Tag` in this `TagSystem`.
+        yield identifier;
+      } else {
+        // The current instance is not a `Tag` in this `TagSystem`.
+        /* do nothing */
+      }
+    }
+  }
+
+  /**
+   * Returns a new `Tag` constructed from the provided data and with
+   * the provided identifier.
+   *
+   * ※ This function will not work if called directly from `Tag` (and
+   * nor is it available *to* be called as such from outside this
+   * module). It must be called from a `TagSystem::Tag` bound
+   * constructor.
+   *
+   * ※ This function is not really intended for public usage.
+   */
+  static [Storage.toInstance](_system, _storage, data, identifier) {
+    const tag = new this(data.kind);
+    tag.#identifier = `${identifier}`;
+    tag.#persistedData = tagData(data);
+    tag.#data = tagData(data);
+    return tag;
+  }
+
+  static {
+    // Overwrite the default `::constructor` method to instead give the
+    // actual (bound) constructor which was used to generate a given
+    // `Tag`.
+    Object.defineProperties(this.prototype, {
+      constructor: {
+        configurable: true,
+        enumerable: false,
+        get() {
+          // All `Tag`s are constructed via the `.Tag` constructor
+          // available in their `TagSystem`; return it.
+          return this.#system.Tag;
+        },
+        set: undefined,
+      },
+    });
+  }
+
+  /** Adds the provided label(s) to this `Tag` as alternate labels. */
+  addAltLabel(...labels) {
+    const altLabels = this.#data.altLabel;
+    let objectLabels = null; // initialized on first use
+    for (const $ of labels) {
+      // Iterate over each provided label and attempt to add it.
+      const literal = langString($);
+      if (Object(literal) === literal) {
+        // The current label is a language‐tagged string.
+        objectLabels ??= [...function* () {
+          for (const altLabel of altLabels) {
+            // Iterate over the existing labels and yield the
+            // language‐tagged strings.
+            if (Object(altLabel) === altLabel) {
+              // The current existing label is a language‐tagged
+              // string.
+              yield altLabel;
+            } else {
+              // The current existing label is not a language‐tagged
+              // string.
+              /* do nothing */
+            }
+          }
+        }()];
+        if (
+          objectLabels.some((objectLabel) =>
+            objectLabel["@value"] == literal["@value"] &&
+            objectLabel["@language"] == literal["@language"]
+          )
+        ) {
+          // There is a match with the current label in the existing
+          // labels.
+          /* do nothing */
+        } else {
+          // There is no match and this label must be added.
+          altLabels.add(literal);
+          objectLabels.push(literal);
+        }
+      } else {
+        // The current label is a simple string.
+        altLabels.add(literal);
+      }
+    }
+  }
+
+  /**
+   * Adds the provided tags to the list of tags that this `Tag` is
+   * narrower than.
+   *
+   * Arguments may be string identifiers or objects with an
+   * `.identifier` property.
+   */
+  addBroaderTag(...tags) {
+    const storage = this.#storage;
+    const broader = this.#data.broader;
+    for (const $ of tags) {
+      // Iterate over each tag and attempt to set it as broader than
+      // this `Tag`.
+      const identifier = toIdentifier($);
+      if (identifier == null) {
+        // ☡ The current tag has no identifier.
+        throw new TypeError(
+          "Cannot assign broader to Tag: Identifier must not be nullish.",
+        );
+      } else if (broader.has(identifier)) {
+        // Short‐circuit: The identifier is already something this
+        // `Tag` is narrower than.
+        /* do nothing */
+      } else {
+        // The current tag has an identifier.
+        const tag = storage.get(identifier);
+        if (tag == null) {
+          // ☡ The current tag has not been persisted to this `Tag`’s
+          // storage.
+          throw new RangeError(
+            `Cannot assign broader to Tag: Identifier is not persisted: ${identifier}.`,
+          );
+        } else if (!this.#isTagInStorage(tag)) {
+          // ☡ The current tag is not a tag in the correct tag system.
+          throw new TypeError(
+            `Cannot assign broader to Tag: Tags must be from the same Tag System, but got: ${identifier}.`,
+          );
+        } else {
+          // The current tag is a tag in the correct tag system; add
+          // its identifier.
+          broader.add(identifier);
+        }
+      }
+    }
+  }
+
+  /** Adds the provided label(s) to this `Tag` as hidden labels. */
+  addHiddenLabel(...labels) {
+    const hiddenLabels = this.#data.hiddenLabel;
+    let objectLabels = null; // initialized on first use
+    for (const $ of labels) {
+      // Iterate over each provided label and attempt to add it.
+      const literal = langString($);
+      if (Object(literal) === literal) {
+        // The current label is a language‐tagged string.
+        objectLabels ??= [...function* () {
+          for (const hiddenLabel of hiddenLabels) {
+            // Iterate over the existing labels and yield the
+            // language‐tagged strings.
+            if (Object(hiddenLabel) === hiddenLabel) {
+              // The current existing label is a language‐tagged
+              // string.
+              yield hiddenLabel;
+            } else {
+              // The current existing label is not a language‐tagged
+              // string.
+              /* do nothing */
+            }
+          }
+        }()];
+        if (
+          objectLabels.some((objectLabel) =>
+            objectLabel["@value"] == literal["@value"] &&
+            objectLabel["@language"] == literal["@language"]
+          )
+        ) {
+          // There is a match with the current label in the existing
+          // labels.
+          /* do nothing */
+        } else {
+          // There is no match and this label must be added.
+          hiddenLabels.add(literal);
+          objectLabels.push(literal);
+        }
+      } else {
+        // The current label is a simple string.
+        hiddenLabels.add(literal);
+      }
+    }
+  }
+
+  /**
+   * Adds the provided tags to the list of tags that this `Tag` is in
+   * canon with.
+   *
+   * Arguments may be string identifiers or objects with an
+   * `.identifier` property.
+   *
+   * ☡ This method will throw if a provided argument does not indicate
+   * a canon tag, or if this `Tag` is not of a kind which can be placed
+   * in canon.
+   */
+  addInCanonTag(...tags) {
+    const storage = this.#storage;
+    const kind = this.#kind;
+    const inCanon = this.#data.inCanon;
+    if (!HAS_IN_CANON.has(kind)) {
+      // ☡ This is not an entity tag, setting tag, or recognized
+      // subclass.
+      throw new TypeError(
+        `Cannot put Tag in canon: Incorrect Tag type: ${kind}.`,
+      );
+    } else {
+      // This has a kind which can be placed in canon.
+      for (const $ of tags) {
+        // Iterate over each tag and attempt to set this `Tag` in canon
+        // of it.
+        const identifier = toIdentifier($);
+        if (identifier == null) {
+          // ☡ The current tag has no identifier.
+          throw new TypeError(
+            "Cannot put Tag in canon: Identifier must not be nullish.",
+          );
+        } else if (inCanon.has(identifier)) {
+          // Short‐circuit: The identifier is already something this
+          // `Tag` is in canon of.
+          /* do nothing */
+        } else {
+          // The current tag has an identifier.
+          const tag = storage.get(identifier);
+          if (tag == null) {
+            // ☡ The current tag has not been persisted to this `Tag`’s
+            // storage.
+            throw new RangeError(
+              `Cannot put Tag in canon: Identifier is not persisted: ${identifier}.`,
+            );
+          } else if (
+            // ※ If the first check succeeds, then the current tag
+            // must have `Tag` private class features.
+            !this.#isTagInStorage(tag) || tag.#kind != "CanonTag"
+          ) {
+            // ☡ The current tag is not a canon tag in the correct
+            // tag system.
+            throw new TypeError(
+              `Cannot put Tag in canon: Tags can only be in Canon Tags from the same Tag System, but got: ${identifier}.`,
+            );
+          } else {
+            // The current tag is a canon tag in the correct tag
+            // system; add its identifier.
+            inCanon.add(identifier);
+          }
+        }
+      }
+    }
+  }
+
+  /**
+   * Adds the provided tags to the list of tags that this `Tag`
+   * involves.
+   *
+   * Arguments may be string identifiers or objects with an
+   * `.identifier` property.
+   *
+   * ☡ This method will throw if this `Tag` is not a conceptual tag, or
+   * if this `Tag` is a relationship tag and a provided argument does
+   * not indicate a character or relationship tag.
+   */
+  addInvolvesTag(...tags) {
+    const storage = this.#storage;
+    const kind = this.#kind;
+    const involves = this.#data.involves;
+    if (!CONCEPTUAL_TAG_KINDS.has(kind)) {
+      // ☡ This is not a conceptual tag or recognized subclass.
+      throw new TypeError(
+        `Cannot involve Tag: Incorrect Tag type: ${kind}.`,
+      );
+    } else {
+      // This is a conceptual tag.
+      for (const $ of tags) {
+        // Iterate over each tag and attempt to set this `Tag` as
+        // involving it.
+        const identifier = toIdentifier($);
+        if (identifier == null) {
+          // ☡ The current tag has no identifier.
+          throw new TypeError(
+            "Cannot involve Tag: Identifier must not be nullish.",
+          );
+        } else if (involves.has(identifier)) {
+          // Short‐circuit: The identifier is already something this
+          // `Tag` involves.
+          /* do nothing */
+        } else {
+          // The current tag has an identifier.
+          const tag = storage.get(identifier);
+          if (tag == null) {
+            // ☡ The current tag has not been persisted to this `Tag`’s
+            // storage.
+            throw new RangeError(
+              `Cannot involve Tag: Identifier is not persisted: ${identifier}.`,
+            );
+          } else if (
+            // ※ If the first check succeeds, then the current tag
+            // must have `Tag` private class features.
+            !this.#isTagInStorage(tag) ||
+            RELATIONSHIP_TAG_KINDS.has(kind) &&
+              !INVOLVABLE_IN_RELATIONSHIP.has(tag.#kind)
+          ) {
+            // ☡ The current tag is in the correct tag system and
+            // includable.
+            throw new TypeError(
+              `Cannot involve Tag: Tags must be the same Tag System and involvable, but got: ${identifier}.`,
+            );
+          } else {
+            // The current tag is an involvable tag in the correct tag
+            // system; add its identifier.
+            involves.add(identifier);
+          }
+        }
+      }
+    }
+  }
+
+  /** Yields the alternative labels of this `Tag`. */
+  *altLabels() {
+    yield* this.#data.altLabel;
+  }
+
+  /** Returns the authority (domain) name for this `Tag`. */
+  get authorityName() {
+    return this.#system.authorityName;
+  }
+
+  /** Yields `Tag`s which are broader than this `Tag`. */
+  *broaderTags() {
+    const storage = this.#storage;
+    for (const identifier of this.#data.broader) {
+      // Iterate over the broader tags and yield them if possible.
+      const tag = storage.get(identifier);
+      if (!this.#isTagInStorage(tag)) {
+        // The broader tag no longer appears in storage; perhaps it was
+        // deleted.
+        /* do nothing */
+      } else {
+        // The broader tag exists and is constructable from storage.
+        yield tag;
+      }
+    }
+  }
+
+  /** Yields `Tag`s which are broader than this `Tag`, transitively. */
+  *broaderTransitiveTags() {
+    const storage = this.#storage;
+    const encountered = new Set();
+    let pending = new Set(this.#data.broader);
+    while (pending.size > 0) {
+      // Loop until all broader tags have been encountered.
+      const processing = pending;
+      pending = new Set();
+      for (const identifier of processing) {
+        // Iterate over the broader tags and yield them if possible.
+        if (!encountered.has(identifier)) {
+          // The broader tag has not been encountered before.
+          encountered.add(identifier);
+          const tag = storage.get(identifier);
+          if (!this.#isTagInStorage(tag)) {
+            // The broader tag no longer appears in storage; perhaps it
+            // was deleted.
+            /* do nothing */
+          } else {
+            // The broader tag exists and is constructable from
+            // storage.
+            yield tag;
+            for (const transitive of tag.#data.broader) {
+              // Iterate over the broader tags of the current broader
+              // tag and add them to pending as needed.
+              if (!encountered.has(transitive)) {
+                // The broader broader tag has not been encountered
+                // yet.
+                pending.add(transitive);
+              } else {
+                // The broader broader tag has already been
+                // encountered.
+                /* do nothing */
+              }
+            }
+          }
+        } else {
+          // The broader tag has already been encountered.
+          /* do nothing */
+        }
+      }
+    }
+  }
+
+  /**
+   * Removes the provided string label(s) from this `Tag` as alternate
+   * labels.
+   */
+  deleteAltLabel(...labels) {
+    const altLabels = this.#data.altLabel;
+    let objectLabels = null; // initialized on first use
+    for (const $ of labels) {
+      // Iterate over each provided label and attempt to remove it.
+      const literal = langString($);
+      if (Object(literal) === literal) {
+        // The current label is a language‐tagged string.
+        objectLabels ??= [...function* () {
+          for (const altLabel of altLabels) {
+            // Iterate over the existing labels and yield the
+            // language‐tagged strings.
+            if (Object(altLabel) === altLabel) {
+              // The current existing label is a language‐tagged
+              // string.
+              yield altLabel;
+            } else {
+              // The current existing label is not a language‐tagged
+              // string.
+              /* do nothing */
+            }
+          }
+        }()];
+        const existing = objectLabels.find((objectLabel) =>
+          objectLabel["@value"] == literal["@value"] &&
+          objectLabel["@language"] == literal["@language"]
+        );
+        altLabels.delete(existing);
+      } else {
+        // The current label is a simple string.
+        altLabels.delete(literal);
+      }
+    }
+  }
+
+  /**
+   * Removes the provided tags from the list of tags that this `Tag` is
+   * narrower than.
+   *
+   * Arguments may be string identifiers or objects with an
+   * `.identifier` property.
+   */
+  deleteBroaderTag(...tags) {
+    const broader = this.#data.broader;
+    for (const $ of tags) {
+      // Iterate over the provided tags and delete them.
+      broader.delete(toIdentifier($));
+    }
+  }
+
+  /**
+   * Removes the provided string label(s) from this `Tag` as hidden
+   * labels.
+   */
+  deleteHiddenLabel(...labels) {
+    const hiddenLabels = this.#data.hiddenLabel;
+    let objectLabels = null; // initialized on first use
+    for (const $ of labels) {
+      // Iterate over each provided label and attempt to remove it.
+      const literal = langString($);
+      if (Object(literal) === literal) {
+        // The current label is a language‐tagged string.
+        objectLabels ??= [...function* () {
+          for (const hiddenLabel of hiddenLabels) {
+            // Iterate over the existing labels and yield the
+            // language‐tagged strings.
+            if (Object(hiddenLabel) === hiddenLabel) {
+              // The current existing label is a language‐tagged
+              // string.
+              yield hiddenLabel;
+            } else {
+              // The current existing label is not a language‐tagged
+              // string.
+              /* do nothing */
+            }
+          }
+        }()];
+        const existing = objectLabels.find((objectLabel) =>
+          objectLabel["@value"] == literal["@value"] &&
+          objectLabel["@language"] == literal["@language"]
+        );
+        hiddenLabels.delete(existing);
+      } else {
+        // The current label is a simple string.
+        hiddenLabels.delete(literal);
+      }
+    }
+  }
+
+  /**
+   * Removes the provided tags from the list of tags that this `Tag` is
+   * in canon with.
+   *
+   * Arguments may be string identifiers or objects with an
+   * `.identifier` property.
+   */
+  deleteInCanonTag(...tags) {
+    const inCanon = this.#data.inCanon;
+    for (const $ of tags) {
+      // Iterate over the provided tags and delete them.
+      inCanon.delete(toIdentifier($));
+    }
+  }
+
+  /**
+   * Removes the provided tags from the list of tags that this `Tag`
+   * involves.
+   *
+   * Arguments may be string identifiers or objects with an
+   * `.identifier` property.
+   */
+  deleteInvolvesTag(...tags) {
+    const involves = this.#data.involves;
+    for (const $ of tags) {
+      // Iterate over the provided tags and delete them.
+      involves.delete(toIdentifier($));
+    }
+  }
+
+  /** Yields `Tag`s that are in canon of this `Tag`. */
+  *hasInCanonTags() {
+    const storage = this.#storage;
+    if (this.#kind == "CanonTag") {
+      // This is a canon tag.
+      for (const identifier of this.#data.hasInCanon) {
+        // Iterate over the tags in canon and yield them if possible.
+        const tag = storage.get(identifier);
+        if (
+          !this.#isTagInStorage(tag) || !HAS_IN_CANON.has(tag.#kind)
+        ) {
+          // The tag in canon no longer appears in storage; perhaps it
+          // was deleted.
+          /* do nothing */
+        } else {
+          // The tag in canon exists and is constructable from storage.
+          yield tag;
+        }
+      }
+    } else {
+      /* do nothing */
+    }
+  }
+
+  /** Yields the hidden labels of this `Tag`. */
+  *hiddenLabels() {
+    yield* this.#data.hiddenLabel;
+  }
+
+  /** Returns the identifier of this `Tag`. */
+  get identifier() {
+    return this.#identifier;
+  }
+
+  /** Yields `Tag`s that this `Tag` is in canon of. */
+  *inCanonTags() {
+    const storage = this.#storage;
+    if (HAS_IN_CANON.has(this.#kind)) {
+      // This tag can be placed in canon.
+      for (const identifier of this.#data.inCanon) {
+        // Iterate over the canon tags and yield them if possible.
+        const tag = storage.get(identifier);
+        if (!this.#isTagInStorage(tag) || tag.#kind != "CanonTag") {
+          // The canon tag no longer appears in storage; perhaps it was
+          // deleted.
+          /* do nothing */
+        } else {
+          // The canon tag exists and is constructable from storage.
+          yield tag;
+        }
+      }
+    } else {
+      // This tag cannot be placed in canon.
+      /* do nothing */
+    }
+  }
+
+  /** Yields `Tag`s which involve this `Tag`. */
+  *involvedInTags() {
+    const storage = this.#storage;
+    for (const identifier of this.#data.involvedIn) {
+      // Iterate over the involving tags and yield them if possible.
+      const tag = storage.get(identifier);
+      const tagKind = tag.#kind;
+      if (
+        !this.#isTagInStorage(tag) ||
+        !CONCEPTUAL_TAG_KINDS.has(tagKind) ||
+        RELATIONSHIP_TAG_KINDS.has(tagKind) &&
+          !INVOLVABLE_IN_RELATIONSHIP.has(this.#kind)
+      ) {
+        // The including tag no longer appears in storage; perhaps it
+        // was deleted.
+        /* do nothing */
+      } else {
+        // The including tag exists and is constructable from storage.
+        yield tag;
+      }
+    }
+  }
+
+  /** Yields `Tag`s that this `Tag` involves. */
+  *involvesTags() {
+    const storage = this.#storage;
+    const kind = this.#kind;
+    if (CONCEPTUAL_TAG_KINDS.has(kind)) {
+      // This tag can involve other tags.
+      for (const identifier of this.#data.involves) {
+        // Iterate over the involved and yield them if possible.
+        const tag = storage.get(identifier);
+        if (
+          !this.#isTagInStorage(tag) ||
+          RELATIONSHIP_TAG_KINDS.has(kind) &&
+            !INVOLVABLE_IN_RELATIONSHIP.has(tag.#kind)
+        ) {
+          // The involved tag no longer appears in storage; perhaps it
+          // was deleted.
+          /* do nothing */
+        } else {
+          // The involved tag exists and is constructable from storage.
+          yield tag;
+        }
+      }
+    } else {
+      // This tag cannot involve other tags.
+      /* do nothing */
+    }
+  }
+
+  /** Returns the I·R·I for this `Tag`. */
+  get iri() {
+    const tagURI = this.tagURI;
+    return tagURI == null
+      ? null
+      : `https://${this.authorityName}/${tagURI}`;
+  }
+
+  /** Returns the kind of this `Tag`. */
+  get kind() {
+    return this.#kind;
+  }
+
+  /** Yields `Tag`s which are narrower than this `Tag`. */
+  *narrowerTags() {
+    const storage = this.#storage;
+    for (const identifier of this.#data.narrower) {
+      const tag = storage.get(identifier);
+      if (!this.#isTagInStorage(tag)) {
+        // The narrower tag no longer appears in storage; perhaps it
+        // was deleted.
+        /* do nothing */
+      } else {
+        // The narrower tag exists and is constructable from storage.
+        yield tag;
+      }
+    }
+  }
+
+  /**
+   * Yields `Tag`s which are narrower than this `Tag`, transitively.
+   */
+  *narrowerTransitiveTags() {
+    const storage = this.#storage;
+    const encountered = new Set();
+    let pending = new Set(this.#data.narrower);
+    while (pending.size > 0) {
+      // Loop until all narrower tags have been encountered.
+      const processing = pending;
+      pending = new Set();
+      for (const identifier of processing) {
+        // Iterate over the narrower tags and yield them if possible.
+        if (!encountered.has(identifier)) {
+          // The narrower tag has not been encountered before.
+          encountered.add(identifier);
+          const tag = storage.get(identifier);
+          if (!this.#isTagInStorage(tag)) {
+            // The narrower tag no longer appears in storage; perhaps
+            // it was deleted.
+            /* do nothing */
+          } else {
+            // The narrower tag exists and is constructable from
+            // storage.
+            yield tag;
+            for (const transitive of tag.#data.narrower) {
+              // Iterate over the narrower tags of the current narrower
+              // tag and add them to pending as needed.
+              if (!encountered.has(transitive)) {
+                // The narrower narrower tag has not been encountered
+                // yet.
+                pending.add(transitive);
+              } else {
+                // The narrower narrower tag has already been
+                // encountered.
+                /* do nothing */
+              }
+            }
+          }
+        } else {
+          // The narrower tag has already been encountered.
+          /* do nothing */
+        }
+      }
+    }
+  }
+
+  /**
+   * Persist this `Tag` to storage and return an ActivityStreams
+   * serialization of a Tag Activity representing any changes, or
+   * `null` if no changes were made.
+   *
+   * ※ Persistence can imply side‐effects on other objects, which are
+   * not noted explicitly in the activity. For example, marking a tag
+   * as broader than another causes the other tag to reciprocally be
+   * marked as narrower.
+   *
+   * ※ The inverse terms `hasInCanon`, `isIncludedIn`, and `narrower`
+   * will never appear in the predicates of generated activities.
+   */
+  persist() {
+    const system = this.#system;
+    const storage = this.#storage;
+    const persistedData = this.#persistedData;
+    const data = this.#data;
+    const diffs = {};
+    for (const [key, value] of Object.entries(data)) {
+      // Iterate over each entry of the tag data and create a diff with
+      // the last persisted information.
+      if (SKIP_IN_DIFF.has(key)) {
+        // The current property is one which is skipped in diffs.
+        /* do nothing */
+      } else {
+        // The current property should be diffed.
+        const persisted = persistedData?.[key] ?? null;
+        if (persisted == null) {
+          // There is no persisted data for the current property yet.
+          diffs[key] = {
+            old: new Set(),
+            new: value instanceof Set
+              ? new Set(value)
+              : new Set([value]),
+          };
+        } else if (value instanceof Set) {
+          // The current property is set‐valued.
+          let values = null; // initialized on first use
+          const oldValues = new Set(persisted);
+          const newValues = new Set(value);
+          for (const existing of persisted) {
+            // Iterate over each persisted property and either remove
+            // it from the list of new values or add it to the list of
+            // removed ones.
+            //
+            // ※ Some special handling is required here for
+            // language‐tagged strings.
+            if (
+              value.has(existing) ||
+              Object(existing) === existing &&
+                (values ??= [...value]).some(($) =>
+                  `${$}` == `${existing}` &&
+                  $.language == existing.language
+                )
+            ) {
+              // The value is in both the old and new version of the
+              // data.
+              oldValues.delete(existing);
+              newValues.delete(existing);
+            } else {
+              // The value is not shared.
+              /* do nothing */
+            }
+          }
+          diffs[key] = {
+            old: oldValues,
+            new: newValues,
+          };
+        } else if (
+          `${value}` != `${persisted}` ||
+          value.language != persisted.language
+        ) {
+          // The current property is (optionally language‐tagged)
+          // string‐valued and the value changed.
+          diffs[key] = {
+            old: new Set([persisted]),
+            new: new Set([value]),
+          };
+        } else {
+          // The current property did not change.
+          diffs[key] = {
+            old: new Set(),
+            new: new Set(),
+          };
+        }
+      }
+    }
+    const identifier = this.#identifier;
+    if (identifier != null) {
+      // This `Tag` has already been persisted; use its existing
+      // identifier and persist.
+      storage.set(identifier, this);
+    } else {
+      // This `Tag` has not been persisted yet; save the new
+      // identifier after persisting.
+      this.#identifier = storage.add(this);
+    }
+    const persistedIdentifier = this.#identifier;
+    this.#persistedData = tagData(data); // need to clone here
+    for (
+      const [term, inverse] of [
+        ["broader", "narrower"],
+        ["inCanon", "hasInCanon"],
+        ["involves", "involvedIn"],
+      ]
+    ) {
+      // Iterate over each term referencing other tags and update the
+      // inverse property on those tags if possible.
+      for (const referencedIdentifier of diffs[term].old) {
+        // Iterate over the removed tags and remove this `Tag` from
+        // their inverse property.
+        const referenced = storage.get(referencedIdentifier);
+        try {
+          // Try removing this `Tag`.
+          referenced.#data[inverse].delete(persistedIdentifier);
+          storage.set(referencedIdentifier, referenced);
+        } catch {
+          // Removal failed, possibly because the other tag was
+          // deleted.
+          /* do nothing */
+        }
+      }
+      for (const referencedIdentifier of diffs[term].new) {
+        const referenced = storage.get(referencedIdentifier);
+        try {
+          // Try adding this `Tag`.
+          referenced.#data[inverse].add(persistedIdentifier);
+          storage.set(referencedIdentifier, referenced);
+        } catch {
+          // Adding failed, possibly because the other tag was deleted.
+          /* do nothing */
+        }
+      }
+    }
+    const activity = {
+      "@context": taggingDiscoveryContext,
+      "@type": [
+        "TagActivity",
+        identifier == null ? "Create" : "Update",
+      ],
+      context: `${system.iri}`,
+      object: `${this.iri}`,
+      endTime: new Date().toISOString(),
+      ...(() => {
+        const statements = {
+          unstates: [],
+          states: [],
+        };
+        const { unstates, states } = statements;
+        if (identifier == null) {
+          // This is a Create activity.
+          states.push({ predicate: "a", object: `${this.kind}` });
+        } else {
+          // This is an Update activity.
+          /* do nothing */
+        }
+        for (
+          const [term, {
+            old: oldValues,
+            new: newValues,
+          }] of Object.entries(diffs)
+        ) {
+          // Iterate over the diffs of each term and state/unstate
+          // things as needed.
+          for (const oldValue of oldValues) {
+            // Iterate over removals and unstate them.
+            if (LITERAL_TERMS.has(term)) {
+              // This is a literal term; push the change wrapped in an
+              // object.
+              unstates.push({
+                predicate: term,
+                object: Object(oldValue) === oldValue
+                  ? { ...langString(oldValue) }
+                  : { "@value": `${oldValue}` },
+              });
+            } else {
+              // This is a named term; attempt to get its I·R·I and
+              // push it.
+              try {
+                // Attempt to resolve the value and push the change.
+                const tag = storage.get(oldValue);
+                if (!this.#isTagInStorage(tag)) {
+                  // The value did not resolve to a tag in storage.
+                  /* do nothing */
+                } else {
+                  // The value resolved; push its I·R·I.
+                  unstates.push({
+                    predicate: term,
+                    object: tag.iri,
+                  });
+                }
+              } catch {
+                // Value resolution failed for some reason; perhaps the
+                // tag was deleted.
+                /* do nothing */
+              }
+            }
+          }
+          for (const newValue of newValues) {
+            // Iterate over additions and state them.
+            if (LITERAL_TERMS.has(term)) {
+              // This is a literal term; push the change wrapped in an
+              // object.
+              states.push({
+                predicate: term,
+                object: Object(newValue) === newValue
+                  ? { ...langString(newValue) }
+                  : { "@value": `${newValue}` },
+              });
+            } else {
+              // This is a named term; attempt to get its I·R·I and
+              // push it.
+              try {
+                // Attempt to resolve the value and push the change.
+                const tag = storage.get(newValue);
+                if (!this.#isTagInStorage(tag)) {
+                  // The value did not resolve to a tag in storage.
+                  /* do nothing */
+                } else {
+                  // The value resolved; push its I·R·I.
+                  states.push({
+                    predicate: term,
+                    object: tag.iri,
+                  });
+                }
+              } catch {
+                // Value resolution failed for some reason; perhaps the
+                // tag was deleted.
+                /* do nothing */
+              }
+            }
+          }
+        }
+        if (unstates.length == 0) {
+          // Nothing was unstated.
+          delete statements.unstates;
+        } else {
+          // Things were stated.
+          /* do nothing */
+        }
+        if (states.length == 0) {
+          // Nothing was stated.
+          delete statements.states;
+        } else {
+          // Things were stated.
+          /* do nothing */
+        }
+        return statements;
+      })(),
+    };
+    if (
+      !Object.hasOwn(activity, "states") &&
+      !Object.hasOwn(activity, "unstates")
+    ) {
+      // No meaningful changes were actually persisted.
+      return null;
+    } else {
+      // There were meaningful changes persisted regarding this `Tag`.
+      return activity;
+    }
+  }
+
+  /** Returns the preferred label for this `Tag`. */
+  get prefLabel() {
+    return this.#data.prefLabel;
+  }
+
+  /** Sets the preferred label of this `Tag` to the provided label. */
+  set prefLabel($) {
+    this.#data.prefLabel = langString($);
+  }
+
+  /** Returns the Tag U·R·I for this `Tag`. */
+  get tagURI() {
+    const { identifier } = this;
+    return identifier == null
+      ? null
+      : `tag:${this.taggingEntity}:${identifier}`;
+  }
+
+  /** Returns the tagging entity (domain and date) for this `Tag`. */
+  get taggingEntity() {
+    return this.#system.taggingEntity;
+  }
+
+  /** Returns the string form of the preferred label of this `Tag`. */
+  toString() {
+    return `${this.#data.prefLabel}`;
+  }
+
+  /**
+   * Returns a new object whose enumerable own properties contain the
+   * data from this object needed for storage.
+   *
+   * ※ This method is not really intended for public usage.
+   */
+  [Storage.toObject]() {
+    const data = this.#data;
+    return Object.assign(Object.create(null), {
+      ...data,
+      kind: this.#kind,
+    });
+  }
+}
+
+const {
+  /**
+   * Returns the provided value converted into either a plain string
+   * primitive or an object with `.["@value"]` and `.["@language"]`
+   * properties.
+   *
+   * TODO: Ideally this would be extracted more fully into an R·D·F
+   * library.
+   *
+   * ※ This function is not exposed.
+   */
+  langString,
+} = (() => {
+  /** Returns the `.["@language"]` of this object. */
+  const getLanguage = Object.defineProperty(
+    function () {
+      return this["@language"];
+    },
+    "name",
+    { value: "get language" },
+  );
+
+  /** Returns the `.["@value"]` of this object. */
+  const toString = function () {
+    return this["@value"];
+  };
+
+  /** Returns the `.["@value"]` of this object. */
+  const valueOf = function () {
+    return this["@value"];
+  };
+
+  return {
+    langString: ($) =>
+      Object($) === $
+        ? "@value" in $
+          ? "@language" in $
+            ? Object.preventExtensions(
+              Object.create(String.prototype, {
+                "@value": {
+                  enumerable: true,
+                  value: `${$["@value"]}`,
+                },
+                "@language": {
+                  enumerable: true,
+                  value: `${$["@language"]}`,
+                },
+                language: { enumerable: false, get: getLanguage },
+                toString: { enumerable: false, value: toString },
+                valueOf: { enumerable: false, value: valueOf },
+              }),
+            )
+            : `${$["@value"]}`
+          : "language" in $
+          ? Object.preventExtensions(
+            Object.create(String.prototype, {
+              "@value": { enumerable: true, value: `${$}` },
+              "@language": {
+                enumerable: true,
+                value: `${$.language}`,
+              },
+              language: { enumerable: false, get: getLanguage },
+              toString: { enumerable: false, value: toString },
+              valueOf: { enumerable: false, value: valueOf },
+            }),
+          )
+          : `${$}`
+        : `${$ ?? ""}`,
+  };
+})();
+
+/**
+ * Returns a normalized tag data object derived from the provided
+ * object.
+ *
+ * ※ The properties of this function need to match the term names used
+ * in the ActivityStreams serialization.
+ *
+ * ※ This function is not exposed.
+ */
+const tagData = ($) => {
+  const data = Object($);
+  const {
+    // prefLabel intentionally not set here
+    altLabel,
+    hiddenLabel,
+    broader,
+    narrower,
+    inCanon,
+    hasInCanon,
+    involves,
+    involvedIn,
+  } = data;
+  let prefLabel = langString(data.prefLabel);
+  return Object.preventExtensions(Object.create(null, {
+    prefLabel: {
+      enumerable: true,
+      get: () => prefLabel,
+      set: ($) => {
+        prefLabel = langString($);
+      },
+    },
+    altLabel: {
+      enumerable: true,
+      value: new Set(
+        altLabel != null
+          ? Array.from(altLabel, langString)
+          : undefined,
+      ),
+    },
+    hiddenLabel: {
+      enumerable: true,
+      value: new Set(
+        hiddenLabel != null
+          ? Array.from(hiddenLabel, langString)
+          : undefined,
+      ),
+    },
+    broader: {
+      enumerable: true,
+      value: new Set(
+        broader != null
+          ? Array.from(broader, toIdentifier)
+          : undefined,
+      ),
+    },
+    narrower: {
+      enumerable: true,
+      value: new Set(
+        narrower != null
+          ? Array.from(narrower, toIdentifier)
+          : undefined,
+      ),
+    },
+    inCanon: {
+      enumerable: true,
+      value: new Set(
+        inCanon != null
+          ? Array.from(inCanon, toIdentifier)
+          : undefined,
+      ),
+    },
+    hasInCanon: {
+      enumerable: true,
+      value: new Set(
+        hasInCanon != null
+          ? Array.from(hasInCanon, toIdentifier)
+          : undefined,
+      ),
+    },
+    involves: {
+      enumerable: true,
+      value: new Set(
+        involves != null
+          ? Array.from(involves, toIdentifier)
+          : undefined,
+      ),
+    },
+    involvedIn: {
+      enumerable: true,
+      value: new Set(
+        involvedIn != null
+          ? Array.from(involvedIn, toIdentifier)
+          : undefined,
+      ),
+    },
+  }));
+};
+
+/**
+ * Returns an identifier corresponding to the provided object.
+ *
+ * This is either the value of its `.identifier` or its string value.
+ *
+ * ※ This function is not exposed.
+ */
+const toIdentifier = ($) =>
+  $ == null
+    ? null
+    : Object($) === $ && "identifier" in $
+    ? $.identifier
+    : `${$}`;
+
+/**
+ * A tag system, with storage.
+ *
+ * The `::Tag` constructor available on any `TagSystem` instance can be
+ * used to create new `Tag`s within the system.
+ */
+export class TagSystem {
+  /** The cached bound `Tag` constructor for this `TagSystem`. */
+  #Tag = null;
+
+  /** The domain of this `TagSystem`. */
+  #domain;
+
+  /** The date of this `TagSystem`. */
+  #date;
+
+  /** The identifier of this `TagSystem`. */
+  #identifier;
+
+  /** The internal `Storage` of this `TagSystem`. */
+  #storage = new Storage();
+
+  /**
+   * Constructs a new `TagSystem` with the provided domain and date.
+   *
+   * Only actual, lowercased domain names are allowed for the domain,
+   * and the date must be “full” (include month and day components).
+   * This is for alignment with general best practices for Tag URI’s.
+   *
+   * ☡ This constructor throws if provided with an invalid date.
+   */
+  constructor(domain, date, identifier = "") {
+    const domainString = `${domain}`;
+    const dateString = `${date}`;
+    this.#identifier = `${identifier}`;
+    try {
+      // If the identifier is a valid storage I·D, reserve it.
+      this.#storage.delete(this.#identifier);
+    } catch {
+      // The identifier is not a valid storage I·D, so no worries.
+      /* do nothing */
+    }
+    if (
+      !/^[a-z](?:[\da-z-]*[\da-z])?(?:\.[a-z](?:[\da-z-]*[\da-z])?)*$/u
+        .test(domainString)
+    ) {
+      // ☡ The domain is invalid.
+      throw new RangeError(`Invalid domain: ${domain}.`);
+    } else if (
+      !/^\d{4}-\d{2}-\d{2}$/u.test(dateString) ||
+      dateString != new Date(dateString).toISOString().split("T")[0]
+    ) {
+      // ☡ The date is invalid.
+      throw new RangeError(`Invalid date: ${date}.`);
+    } else {
+      // The domain and date are 🆗.
+      this.#domain = domainString;
+      this.#date = dateString;
+    }
+  }
+
+  /**
+   * Returns a bound constructor for constructing `Tags` in this
+   * `TagSystem`.
+   */
+  get Tag() {
+    if (this.#Tag != null) {
+      // A bound constructor has already been generated; return it.
+      return this.#Tag;
+    } else {
+      // No bound constructor has been created yet.
+      const storage = this.#storage;
+      const BoundTag = Tag.bind(undefined, this, storage);
+      return this.#Tag = Object.defineProperties(BoundTag, {
+        all: {
+          configurable: true,
+          enumerable: false,
+          value: Tag.all.bind(BoundTag, this, storage),
+          writable: true,
+        },
+        fromIRI: {
+          configurable: true,
+          enumerable: false,
+          value: Tag.fromIRI.bind(BoundTag, this, storage),
+          writable: true,
+        },
+        fromIdentifier: {
+          configurable: true,
+          enumerable: false,
+          value: Tag.fromIdentifier.bind(BoundTag, this, storage),
+          writable: true,
+        },
+        fromTagURI: {
+          configurable: true,
+          enumerable: false,
+          value: Tag.fromTagURI.bind(BoundTag, this, storage),
+          writable: true,
+        },
+        identifiers: {
+          configurable: true,
+          enumerable: false,
+          value: Tag.identifiers.bind(BoundTag, this, storage),
+          writable: true,
+        },
+        name: { value: `${this.tagURI}#${Tag.name}` },
+        prototype: { value: Tag.prototype },
+        [Storage.toInstance]: {
+          configurable: true,
+          enumerable: false,
+          value: Tag[Storage.toInstance].bind(BoundTag, this, storage),
+          writable: true,
+        },
+      });
+    }
+  }
+
+  /** Returns the authority name (domain) for this `TagSystem`. */
+  get authorityName() {
+    return this.#domain;
+  }
+
+  /** Returns the date of this `TagSystem`, as a string. */
+  get date() {
+    return this.#date;
+  }
+
+  /**
+   * Yields the entities in this `TagSystem`.
+   *
+   * ※ Entities can hypothetically be anything. If you specifically
+   * want the `Tag`s, use `::Tag.all` instead.
+   */
+  *entities() {
+    yield* this.#storage.values();
+  }
+
+  /**
+   * Returns the identifier of this `TagSystem`.
+   *
+   * ※ Often this is just the empty string.
+   */
+  get identifier() {
+    return this.#identifier;
+  }
+
+  /** Yields the identifiers in use in this `TagSystem`. */
+  *identifiers() {
+    yield* this.#storage.keys();
+  }
+
+  /** Returns the I·R·I for this `TagSystem`. */
+  get iri() {
+    return `https://${this.authorityName}/${this.tagURI}`;
+  }
+
+  /** Returns the Tag U·R·I for this `TagSystem`. */
+  get tagURI() {
+    return `tag:${this.taggingEntity}:${this.identifier}`;
+  }
+
+  /**
+   * Returns the tagging entity (domain and date) for this `TagSystem`.
+   */
+  get taggingEntity() {
+    return `${this.authorityName},${this.date}`;
+  }
+}
diff --git a/model.test.js b/model.test.js
new file mode 100644 (file)
index 0000000..7944238
--- /dev/null
@@ -0,0 +1,1000 @@
+// 📧🏷️ Étiquette ∷ model.test.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 {
+  assert,
+  assertArrayIncludes,
+  assertEquals,
+  assertFalse,
+  assertObjectMatch,
+  assertStrictEquals,
+  assertThrows,
+  beforeEach,
+  describe,
+  it,
+} from "./dev-deps.js";
+import { TagSystem } from "./model.js";
+
+describe("TagSystem", () => {
+  it("[[Call]] throws", () => {
+    assertThrows(() => {
+      TagSystem();
+    });
+  });
+
+  it("[[Construct]] creates a new TagSystem", () => {
+    assertStrictEquals(
+      Object.getPrototypeOf(new TagSystem("example", "1972-12-31")),
+      TagSystem.prototype,
+    );
+  });
+
+  it("[[Construct]] uses the identifier if provided", () => {
+    assertStrictEquals(
+      new TagSystem("example", "1972-12-31", "etaoin").identifier,
+      "etaoin",
+    );
+  });
+
+  it("[[Construct]] uses an empty identifier if none is provided", () => {
+    assertStrictEquals(
+      new TagSystem("example", "1972-12-31").identifier,
+      "",
+    );
+  });
+
+  it("[[Construct]] throws if provided an invalid domain", () => {
+    assertThrows(() => {
+      new TagSystem("example@example", "1972-12-31");
+    });
+    assertThrows(() => {
+      new TagSystem("0.0.0.0", "1972-12-31");
+    });
+  });
+
+  it("[[Construct]] throws if provided an invalid date", () => {
+    assertThrows(() => {
+      new TagSystem("example", "1969");
+    });
+    assertThrows(() => {
+      new TagSystem("example", "1972-12-31T00:00:00Z");
+    });
+  });
+
+  describe("::Tag", () => {
+    let Tag;
+    let system;
+
+    beforeEach(() => {
+      system = new TagSystem("example", "1972-12-31");
+      Tag = system.Tag;
+    });
+
+    it("[[Get]] returns the same value every time", () => {
+      assertStrictEquals(Tag, system.Tag);
+    });
+
+    it("[[Call]] throws", () => {
+      assertThrows(() => {
+        Tag();
+      });
+    });
+
+    it("[[Construct]] returns a new Tag", () => {
+      assertStrictEquals(
+        Object.getPrototypeOf(new Tag()),
+        Tag.prototype,
+      );
+    });
+
+    it('[[Construct]] defaults the kind to "Tag"', () => {
+      assertStrictEquals(new Tag().kind, "Tag");
+    });
+
+    it("[[Construct]] correctly sets the tag kind", () => {
+      assertStrictEquals(
+        new Tag("RelationshipTag").kind,
+        "RelationshipTag",
+      );
+    });
+
+    it("[[Construct]] defaults the preferred label to the empty string", () => {
+      assertStrictEquals(new Tag().prefLabel, "");
+    });
+
+    it("[[Construct]] correctly sets the preferred label to a simple string", () => {
+      assertStrictEquals(
+        new Tag("RelationshipTag", "Shadow, Me").prefLabel,
+        "Shadow, Me",
+      );
+    });
+
+    it("[[Construct]] initializes tag identifiers to null", () => {
+      assertStrictEquals(
+        new Tag().identifier,
+        null,
+      );
+    });
+
+    it("[[Construct]] correctly sets the preferred label to a language‐tagged string", () => {
+      assertEquals(
+        {
+          ...new Tag("RelationshipTag", {
+            "@value": "Shadow, Me",
+            "@language": "en",
+          }).prefLabel,
+        },
+        { "@value": "Shadow, Me", "@language": "en" },
+      );
+    });
+
+    it("[[Construct]] throws if the tag kind is not recognized", () => {
+      assertThrows(() => {
+        new Tag("NotATag");
+      });
+    });
+
+    describe(".all", () => {
+      it("[[Call]] yields all the persisted tags", () => {
+        const tags = new Set(function* () {
+          let i = 0;
+          while (i++ < 5) {
+            // Generate 5 tags and remember their identifiers.
+            const tag = new Tag();
+            tag.persist();
+            yield tag.identifier;
+          }
+        }());
+        for (const tag of Tag.all()) {
+          assertStrictEquals(
+            Object.getPrototypeOf(tag),
+            Tag.prototype,
+          );
+        }
+        assertEquals(
+          new Set(Array.from(Tag.all(), (tag) => tag.identifier)),
+          tags,
+        );
+      });
+    });
+
+    describe(".fromIRI", () => {
+      it("[[Call]] returns the persisted tag with the given I·R·I", () => {
+        const tag = new Tag();
+        tag.persist();
+        const { identifier, iri } = tag;
+        const retrieved = Tag.fromIRI(iri);
+        assertStrictEquals(
+          Object.getPrototypeOf(retrieved),
+          Tag.prototype,
+        );
+        assertStrictEquals(retrieved.identifier, identifier);
+      });
+
+      it("[[Call]] returns null if no tag with the given I·R·I has been persisted", () => {
+        assertStrictEquals(
+          Tag.fromIRI(
+            `https://${system.authorityName}/tag:${system.taggingEntity}:000-0000`,
+          ),
+          null,
+        );
+      });
+
+      it("[[Call]] returns null if passed an invalid I·R·I", () => {
+        assertStrictEquals(Tag.fromIRI(`bad iri`), null);
+      });
+    });
+
+    describe(".fromIdentifier", () => {
+      it("[[Call]] returns the persisted tag with the given identifier", () => {
+        const tag = new Tag();
+        tag.persist();
+        const { identifier } = tag;
+        const retrieved = Tag.fromIdentifier(identifier);
+        assertStrictEquals(
+          Object.getPrototypeOf(retrieved),
+          Tag.prototype,
+        );
+        assertStrictEquals(retrieved.identifier, identifier);
+      });
+
+      it("[[Call]] returns null if no tag with the given identifier has been persisted", () => {
+        assertStrictEquals(Tag.fromIdentifier("000-0000"), null);
+      });
+
+      it("[[Call]] throws if passed an invalid identifier", () => {
+        assertThrows(() => {
+          Tag.fromIdentifier(""); // wrong format
+        });
+        assertThrows(() => {
+          Tag.fromIdentifier("100-0000"); // bad checksum
+        });
+      });
+    });
+
+    describe(".fromTagURI", () => {
+      it("[[Call]] returns the persisted tag with the given Tag U·R·I", () => {
+        const tag = new Tag();
+        tag.persist();
+        const { identifier, tagURI } = tag;
+        const retrieved = Tag.fromTagURI(tagURI);
+        assertStrictEquals(
+          Object.getPrototypeOf(retrieved),
+          Tag.prototype,
+        );
+        assertStrictEquals(retrieved.identifier, identifier);
+      });
+
+      it("[[Call]] returns null if no tag with the given Tag U·R·I has been persisted", () => {
+        assertStrictEquals(
+          Tag.fromIRI(`tag:${system.taggingEntity}:`),
+          null,
+        );
+        assertStrictEquals(
+          Tag.fromIRI(`tag:${system.taggingEntity}:000-0000`),
+          null,
+        );
+      });
+
+      it("[[Call]] throws if passed an invalid Tag U·R·I", () => {
+        assertThrows(() => {
+          Tag.fromTagURI(""); // wrong format
+        });
+        assertThrows(() => {
+          Tag.fromTagURI(
+            "tag:unexample,1970-01-01:Z", // incorrect tagging entity
+          );
+        });
+      });
+    });
+
+    describe(".getSystem", () => {
+      it("[[Has]] is not present", () => {
+        assertFalse("getSystem" in Tag);
+      });
+    });
+
+    describe(".identifiers", () => {
+      it("[[Call]] yields all the persisted identifiers", () => {
+        const tags = new Set(function* () {
+          let i = 0;
+          while (i++ < 5) {
+            // Generate 5 tags and remember their identifiers.
+            const tag = new Tag();
+            tag.persist();
+            yield tag.identifier;
+          }
+        }());
+        assertEquals(
+          new Set(Tag.identifiers()),
+          tags,
+        );
+      });
+    });
+
+    // `.[Storage.toInstance]` is tested by `.fromIdentifier`.
+
+    describe("::addAltLabel", () => {
+      it("[[Call]] does nothing if called with no arguments", () => {
+        const tag = new Tag();
+        tag.addAltLabel();
+        assertEquals([...tag.altLabels()], []);
+      });
+
+      it("[[Call]] adds the provided alternative labels", () => {
+        const tag = new Tag();
+        tag.addAltLabel(
+          "one",
+          { "@value": "two" },
+          { "@value": "three", "@language": "en" },
+        );
+        assertEquals(
+          Array.from(
+            tag.altLabels(),
+            ($) => typeof $ == "string" ? $ : { ...$ },
+          ),
+          [
+            "one",
+            "two",
+            { "@value": "three", "@language": "en" },
+          ],
+        );
+      });
+    });
+
+    describe("::addBroaderTag", () => {
+      it("[[Call]] does nothing if called with no arguments", () => {
+        const tag = new Tag();
+        tag.addBroaderTag();
+        assertEquals([...tag.broaderTags()], []);
+      });
+
+      it("[[Call]] adds the provided broader tags", () => {
+        const broader = new Tag();
+        broader.persist();
+        const broader2 = new Tag();
+        broader2.persist();
+        const tag = new Tag();
+        tag.addBroaderTag(broader, broader2);
+        assertEquals(
+          Array.from(tag.broaderTags(), ($) => $.identifier),
+          [broader.identifier, broader2.identifier],
+        );
+      });
+
+      it("[[Call]] throws when adding a non‐persisted tag", () => {
+        const tag = new Tag();
+        assertThrows(() => {
+          tag.addBroaderTag(new Tag());
+        });
+      });
+
+      it("[[Call]] throws when adding an unrecognized identifier", () => {
+        const tag = new Tag();
+        assertThrows(() => {
+          tag.addBroaderTag("000-0000"); // not persisted
+        });
+        assertThrows(() => {
+          tag.addBroaderTag(""); // bad format
+        });
+      });
+    });
+
+    describe("::addHiddenLabel", () => {
+      it("[[Call]] does nothing if called with no arguments", () => {
+        const tag = new Tag();
+        tag.addHiddenLabel();
+        assertEquals([...tag.hiddenLabels()], []);
+      });
+
+      it("[[Call]] adds the provided hidden labels", () => {
+        const tag = new Tag();
+        tag.addHiddenLabel(
+          "one",
+          { "@value": "two" },
+          { "@value": "three", "@language": "en" },
+        );
+        assertEquals(
+          Array.from(
+            tag.hiddenLabels(),
+            ($) => typeof $ == "string" ? $ : { ...$ },
+          ),
+          [
+            "one",
+            "two",
+            { "@value": "three", "@language": "en" },
+          ],
+        );
+      });
+    });
+
+    describe("::addInCanonTag", () => {
+      it("[[Call]] does nothing if called with no arguments", () => {
+        const tag = new Tag("EntityTag");
+        tag.addInCanonTag();
+        assertEquals([...tag.inCanonTags()], []);
+      });
+
+      it("[[Call]] adds the provided canon tags", () => {
+        const canon = new Tag("CanonTag");
+        canon.persist();
+        const canon2 = new Tag("CanonTag");
+        canon2.persist();
+        const tag = new Tag("EntityTag");
+        tag.addInCanonTag(canon, canon2);
+        assertEquals(
+          Array.from(tag.inCanonTags(), ($) => $.identifier),
+          [canon.identifier, canon2.identifier],
+        );
+      });
+
+      it("[[Call]] throws when this is not a tag which can be placed in canon", () => {
+        assertThrows(() => {
+          new Tag().addInCanonTag();
+        });
+      });
+
+      it("[[Call]] throws when provided with a non‐canon tag", () => {
+        const notCanon = new Tag();
+        notCanon.persist();
+        const tag = new Tag("EntityTag");
+        assertThrows(() => {
+          tag.addInCanonTag(notCanon);
+        });
+      });
+
+      it("[[Call]] throws when adding a non‐persisted tag", () => {
+        const tag = new Tag("EntityTag");
+        assertThrows(() => {
+          tag.addInCanonTag(new Tag("CanonTag"));
+        });
+      });
+
+      it("[[Call]] throws when adding an unrecognized identifier", () => {
+        const tag = new Tag("EntityTag");
+        assertThrows(() => {
+          tag.addInCanonTag("000-0000"); // not persisted
+        });
+        assertThrows(() => {
+          tag.addInCanonTag(""); // bad format
+        });
+      });
+    });
+
+    describe("::addInvolvesTag", () => {
+      it("[[Call]] does nothing if called with no arguments", () => {
+        const tag = new Tag("ConceptualTag");
+        tag.addInvolvesTag();
+        assertEquals([...tag.involvesTags()], []);
+      });
+
+      it("[[Call]] adds the provided tags", () => {
+        const involved = new Tag();
+        involved.persist();
+        const involved2 = new Tag();
+        involved2.persist();
+        const tag = new Tag("ConceptualTag");
+        tag.addInvolvesTag(involved, involved2);
+        assertEquals(
+          Array.from(tag.involvesTags(), ($) => $.identifier),
+          [involved.identifier, involved2.identifier],
+        );
+      });
+
+      it("[[Call]] throws when this is not a conceptual tag", () => {
+        assertThrows(() => {
+          new Tag().addInvolvesTag();
+        });
+      });
+
+      it("[[Call]] throws when this is a relationship tag and provided with a non‐involvable tag", () => {
+        const notInvolved = new Tag();
+        notInvolved.persist();
+        const tag = new Tag("RelationshipTag");
+        assertThrows(() => {
+          tag.addInvolvesTag(notInvolved);
+        });
+      });
+
+      it("[[Call]] throws when adding a non‐persisted tag", () => {
+        const tag = new Tag("ConceptualTag");
+        assertThrows(() => {
+          tag.addInvolvesTag(new Tag());
+        });
+      });
+
+      it("[[Call]] throws when adding an unrecognized identifier", () => {
+        const tag = new Tag("ConceptualTag");
+        assertThrows(() => {
+          tag.addInvolvesTag("000-0000"); // not persisted
+        });
+        assertThrows(() => {
+          tag.addInvolvesTag(""); // bad format
+        });
+      });
+    });
+
+    // `::altLabels` is tested by `::addAltLabel`.
+
+    describe("::authorityName", () => {
+      it("[[Get]] returns the authority name of the tag system", () => {
+        assertStrictEquals(
+          new Tag().authorityName,
+          system.authorityName,
+        );
+      });
+    });
+
+    // `::broaderTags` is tested by `::addBroaderTag`.
+
+    describe("::broaderTransitiveTags", () => {
+      it("[[Call]] returns broader tags transitively", () => {
+        const superBroad = new Tag();
+        superBroad.persist();
+        const broad = new Tag();
+        broad.addBroaderTag(superBroad);
+        broad.persist();
+        const tag = new Tag();
+        tag.addBroaderTag(broad);
+        assertEquals(
+          Array.from(tag.broaderTransitiveTags(), ($) => $.identifier),
+          [broad.identifier, superBroad.identifier],
+        );
+      });
+
+      it("[[Call]] cannot recurse infinitely", () => {
+        const tag = new Tag();
+        tag.persist();
+        const broad = new Tag();
+        broad.addBroaderTag(tag);
+        broad.persist();
+        tag.addBroaderTag(broad);
+        tag.persist();
+        assertEquals(
+          Array.from(tag.broaderTransitiveTags(), ($) => $.identifier),
+          [broad.identifier, tag.identifier],
+        );
+      });
+    });
+
+    describe("::deleteAltLabel", () => {
+      it("[[Call]] does nothing if called with no arguments", () => {
+        const tag = new Tag();
+        tag.addAltLabel("etaoin");
+        tag.deleteAltLabel();
+        assertEquals([...tag.altLabels()], ["etaoin"]);
+      });
+
+      it("[[Call]] deletes only the provided hidden labels", () => {
+        const tag = new Tag();
+        tag.addAltLabel(
+          "one",
+          "two",
+          { "@value": "three", "@language": "en" },
+          "four",
+        );
+        tag.deleteAltLabel(
+          "one",
+          { "@value": "two" },
+          { "@value": "three", "@language": "en" },
+          { "@value": "four", "@language": "en" },
+        );
+        assertEquals([...tag.altLabels()], ["four"]);
+      });
+    });
+
+    describe("::deleteBroaderTag", () => {
+      it("[[Call]] does nothing if called with no arguments", () => {
+        const broader = new Tag();
+        broader.persist();
+        const tag = new Tag();
+        tag.addBroaderTag(broader);
+        tag.deleteBroaderTag();
+        assertEquals(
+          Array.from(tag.broaderTags(), ($) => $.identifier),
+          [broader.identifier],
+        );
+      });
+
+      it("[[Call]] deletes only the provided broader tags", () => {
+        const superBroader = new Tag();
+        superBroader.persist();
+        const broader = new Tag();
+        broader.addBroaderTag(superBroader);
+        broader.persist();
+        const broader2 = new Tag();
+        broader2.addBroaderTag(superBroader);
+        broader2.persist();
+        const tag = new Tag();
+        tag.addBroaderTag(broader, broader2);
+        tag.deleteBroaderTag(broader, superBroader, "000-0000", "");
+        assertEquals(
+          Array.from(tag.broaderTags(), ($) => $.identifier),
+          [broader2.identifier],
+        );
+      });
+    });
+
+    describe("::deleteHiddenLabel", () => {
+      it("[[Call]] does nothing if called with no arguments", () => {
+        const tag = new Tag();
+        tag.addHiddenLabel("etaoin");
+        tag.deleteHiddenLabel();
+        assertEquals([...tag.hiddenLabels()], ["etaoin"]);
+      });
+
+      it("[[Call]] deletes only the provided alternative labels", () => {
+        const tag = new Tag();
+        tag.addHiddenLabel(
+          "one",
+          "two",
+          { "@value": "three", "@language": "en" },
+          "four",
+        );
+        tag.deleteHiddenLabel(
+          "one",
+          { "@value": "two" },
+          { "@value": "three", "@language": "en" },
+          { "@value": "four", "@language": "en" },
+        );
+        assertEquals([...tag.hiddenLabels()], ["four"]);
+      });
+    });
+
+    describe("::deleteInCanonTag", () => {
+      it("[[Call]] does nothing if called with no arguments", () => {
+        const canon = new Tag("CanonTag");
+        canon.persist();
+        const tag = new Tag("EntityTag");
+        tag.addInCanonTag(canon);
+        tag.deleteInCanonTag();
+        assertEquals(
+          Array.from(tag.inCanonTags(), ($) => $.identifier),
+          [canon.identifier],
+        );
+      });
+
+      it("[[Call]] deletes only the provided canon tags", () => {
+        const canon = new Tag("CanonTag");
+        canon.persist();
+        const canon2 = new Tag("CanonTag");
+        canon2.persist();
+        const tag = new Tag("EntityTag");
+        tag.addInCanonTag(canon, canon2);
+        tag.deleteInCanonTag(canon, "000-0000", "");
+        assertEquals(
+          Array.from(tag.inCanonTags(), ($) => $.identifier),
+          [canon2.identifier],
+        );
+      });
+    });
+
+    describe("::deleteInvolvesTag", () => {
+      it("[[Call]] does nothing if called with no arguments", () => {
+        const involved = new Tag();
+        involved.persist();
+        const tag = new Tag("ConceptualTag");
+        tag.addInvolvesTag(involved);
+        tag.deleteInvolvesTag();
+        assertEquals(
+          Array.from(tag.involvesTags(), ($) => $.identifier),
+          [involved.identifier],
+        );
+      });
+
+      it("[[Call]] deletes only the provided involved tags", () => {
+        const character = new Tag("CharacterTag");
+        character.persist();
+        const involved = new Tag("RelationshipTag");
+        involved.addInvolvesTag(character);
+        involved.persist();
+        const involved2 = new Tag("RelationshipTag");
+        involved2.addInvolvesTag(character);
+        involved2.persist();
+        const tag = new Tag("RelationshipTag");
+        tag.addInvolvesTag(involved, involved2);
+        tag.deleteInvolvesTag(involved, character, "000-0000", "");
+        assertEquals(
+          Array.from(tag.involvesTags(), ($) => $.identifier),
+          [involved2.identifier],
+        );
+      });
+    });
+
+    describe("::hasInCanonTags", () => {
+      it("[[Call]] yields the persisted tags which have this tag in canon", () => {
+        const canon = new Tag("CanonTag");
+        canon.persist();
+        const entity = new Tag("EntityTag");
+        entity.addInCanonTag(canon);
+        entity.persist();
+        const entity2 = new Tag("EntityTag");
+        entity2.addInCanonTag(canon);
+        entity2.persist();
+        const tag = Tag.fromIdentifier(canon.identifier); // reload
+        assertEquals(
+          Array.from(tag.hasInCanonTags(), ($) => $.identifier),
+          [entity.identifier, entity2.identifier],
+        );
+      });
+    });
+
+    // `::hiddenLabels` is tested by `::addHiddenLabel`.
+
+    // `::identifier` is tested by a `.fromIdentifier`.
+
+    // `::inCanonTags` is tested by `::addInCanonTag`.
+
+    describe("::involvedInTags", () => {
+      it("[[Call]] yields the persisted tags which involve this tag", () => {
+        const involved = new Tag();
+        involved.persist();
+        const conceptual = new Tag("ConceptualTag");
+        conceptual.addInvolvesTag(involved);
+        conceptual.persist();
+        const conceptual2 = new Tag("ConceptualTag");
+        conceptual2.addInvolvesTag(involved);
+        conceptual2.persist();
+        const tag = Tag.fromIdentifier(involved.identifier); // reload
+        assertEquals(
+          Array.from(tag.involvedInTags(), ($) => $.identifier),
+          [conceptual.identifier, conceptual2.identifier],
+        );
+      });
+    });
+
+    // `::involvesTags` is tested by `::addInvolvesTag`.
+
+    // `::iri` is tested by a `.fromIRI`.
+
+    // `::kind` is tested by the constructor.
+
+    describe("::narrowerTags", () => {
+      it("[[Call]] yields the persisted tags which are narrower than this tag", () => {
+        const broader = new Tag();
+        broader.persist();
+        const narrower = new Tag();
+        narrower.addBroaderTag(broader);
+        narrower.persist();
+        const narrower2 = new Tag();
+        narrower2.addBroaderTag(broader);
+        narrower2.persist();
+        const tag = Tag.fromIdentifier(broader.identifier); // reload
+        assertEquals(
+          Array.from(tag.narrowerTags(), ($) => $.identifier),
+          [narrower.identifier, narrower2.identifier],
+        );
+      });
+    });
+
+    describe("::narrowerTransitiveTags", () => {
+      it("[[Call]] returns narrower tags transitively", () => {
+        const broad = new Tag();
+        broad.persist();
+        const narrow = new Tag();
+        narrow.addBroaderTag(broad);
+        narrow.persist();
+        const superNarrow = new Tag();
+        superNarrow.addBroaderTag(narrow);
+        superNarrow.persist();
+        const tag = Tag.fromIdentifier(broad.identifier); // reload
+        assertEquals(
+          Array.from(
+            tag.narrowerTransitiveTags(),
+            ($) => $.identifier,
+          ),
+          [narrow.identifier, superNarrow.identifier],
+        );
+      });
+
+      it("[[Call]] cannot recurse infinitely", () => {
+        const tag = new Tag();
+        tag.persist();
+        const broad = new Tag();
+        broad.addBroaderTag(tag);
+        broad.persist();
+        tag.addBroaderTag(broad);
+        tag.persist();
+        assertEquals(
+          Array.from(tag.broaderTransitiveTags(), ($) => $.identifier),
+          [broad.identifier, tag.identifier],
+        );
+      });
+    });
+
+    describe("::persist", () => {
+      it("[[Call]] returns an object with expected properties if there were changes", () => {
+        const tag = new Tag();
+        const activity = tag.persist();
+        assertObjectMatch(
+          activity,
+          {
+            "@context":
+              "https://ns.1024.gdn/Tagging/discovery.context.jsonld",
+            context: system.iri,
+            object: tag.iri,
+          },
+        );
+        assertArrayIncludes(activity["@type"], ["TagActivity"]);
+        assert("endTime" in activity);
+      });
+
+      it("[[Call]] returns a Create activity with a type predicate for new objects", () => {
+        const activity = new Tag().persist();
+        assertEquals(activity["@type"], ["TagActivity", "Create"]);
+        assertArrayIncludes(activity.states, [{
+          predicate: "a",
+          object: "Tag",
+        }]);
+      });
+
+      it("[[Call]] returns an Update activity for old objects", () => {
+        const tag = new Tag();
+        tag.persist();
+        tag.prefLabel = "etaoin";
+        const activity = tag.persist();
+        assertEquals(activity["@type"], ["TagActivity", "Update"]);
+      });
+
+      it("[[Call]] states and unstates changes", () => {
+        const broader1 = new Tag();
+        broader1.persist();
+        const broader2 = new Tag();
+        broader2.persist();
+        const tag = new Tag();
+        tag.addBroaderTag(broader1);
+        tag.persist();
+        tag.prefLabel = "etaoin";
+        tag.deleteBroaderTag(broader1);
+        tag.addBroaderTag(broader2);
+        const activity = tag.persist();
+        assertObjectMatch(activity, {
+          unstates: [
+            { predicate: "prefLabel", object: { "@value": "" } },
+            { predicate: "broader", object: broader1.iri },
+          ],
+          states: [
+            { predicate: "prefLabel", object: { "@value": "etaoin" } },
+            { predicate: "broader", object: broader2.iri },
+          ],
+        });
+      });
+
+      it("[[Call]] doesn’t state if there are no additions", () => {
+        const tag = new Tag();
+        tag.addAltLabel("etaoin");
+        tag.persist();
+        tag.deleteAltLabel("etaoin");
+        const activity = tag.persist();
+        assertFalse("state" in activity);
+      });
+
+      it("[[Call]] doesn’t unstate if there are no removals", () => {
+        const tag = new Tag();
+        tag.persist();
+        tag.addAltLabel("etaoin");
+        const activity = tag.persist();
+        assertFalse("unstate" in activity);
+      });
+
+      it("[[Call]] returns null if no meaningful changes were made", () => {
+        const tag = new Tag();
+        tag.persist();
+        const activity = tag.persist();
+        assertStrictEquals(activity, null);
+      });
+    });
+
+    describe("::prefLabel", () => {
+      it("[[Set]] sets the preferred label", () => {
+        const tag = new Tag();
+        tag.prefLabel = "one";
+        assertStrictEquals(tag.prefLabel, "one");
+        tag.prefLabel = { "@value": "two" };
+        assertStrictEquals(tag.prefLabel, "two");
+        tag.prefLabel = { "@value": "three", "@language": "en" };
+        assertEquals(
+          { ...tag.prefLabel },
+          { "@value": "three", "@language": "en" },
+        );
+      });
+    });
+
+    // `::tagURI` is tested by a `.fromTagURI`.
+
+    describe("::taggingEntity", () => {
+      it("[[Get]] returns the tagging entity of the tag system", () => {
+        assertStrictEquals(
+          new Tag().taggingEntity,
+          system.taggingEntity,
+        );
+      });
+    });
+
+    describe("::toString", () => {
+      it("[[Get]] returns the string value of the preferred label", () => {
+        const tag = new Tag();
+        tag.prefLabel = { "@value": "etaoin", "@language": "zxx" };
+        assertStrictEquals(tag.toString(), "etaoin");
+      });
+    });
+
+    // `::[Storage.toObject]` is tested by `::persist`.
+  });
+
+  describe("::authorityName", () => {
+    it("[[Get]] returns the authority name", () => {
+      const system = new TagSystem("etaoin.example", "1972-12-31");
+      assertStrictEquals(system.authorityName, "etaoin.example");
+    });
+  });
+
+  describe("::authorityName", () => {
+    it("[[Get]] returns the date", () => {
+      const system = new TagSystem("etaoin.example", "1972-12-31");
+      assertStrictEquals(system.date, "1972-12-31");
+    });
+  });
+
+  describe("::identifiers", () => {
+    it("[[Get]] yields the extant entities", () => {
+      const system = new TagSystem("etaoin.example", "1972-12-31");
+      const tags = new Set(function* () {
+        let i = 0;
+        while (i++ < 5) {
+          // Generate 5 tags and remember their identifiers.
+          const tag = new system.Tag();
+          tag.persist();
+          yield tag.identifier;
+        }
+      }());
+      assertEquals(
+        new Set(Array.from(system.entities(), ($) => $.identifier)),
+        tags,
+      );
+    });
+  });
+
+  describe("::identifier", () => {
+    it("[[Get]] returns the identifier", () => {
+      const system = new TagSystem("etaoin.example", "1972-12-31");
+      assertStrictEquals(system.identifier, "");
+      const system2 = new TagSystem(
+        "etaoin.example",
+        "1972-12-31",
+        "etaoin",
+      );
+      assertStrictEquals(system2.identifier, "etaoin");
+    });
+  });
+
+  describe("::identifiers", () => {
+    it("[[Get]] yields the identifiers in use", () => {
+      const system = new TagSystem("etaoin.example", "1972-12-31");
+      const tags = new Set(function* () {
+        let i = 0;
+        while (i++ < 5) {
+          // Generate 5 tags and remember their identifiers.
+          const tag = new system.Tag();
+          tag.persist();
+          yield tag.identifier;
+        }
+      }());
+      assertEquals(new Set(system.identifiers()), tags);
+    });
+  });
+
+  describe("::iri", () => {
+    it("[[Get]] returns the I·R·I", () => {
+      const system = new TagSystem("etaoin.example", "1972-12-31");
+      assertStrictEquals(
+        system.iri,
+        "https://etaoin.example/tag:etaoin.example,1972-12-31:",
+      );
+      const system2 = new TagSystem(
+        "etaoin.example",
+        "1972-12-31",
+        "etaoin",
+      );
+      assertStrictEquals(
+        system2.iri,
+        "https://etaoin.example/tag:etaoin.example,1972-12-31:etaoin",
+      );
+    });
+  });
+
+  describe("::tagURI", () => {
+    it("[[Get]] returns the Tag U·R·I", () => {
+      const system = new TagSystem("etaoin.example", "1972-12-31");
+      assertStrictEquals(
+        system.tagURI,
+        "tag:etaoin.example,1972-12-31:",
+      );
+      const system2 = new TagSystem(
+        "etaoin.example",
+        "1972-12-31",
+        "etaoin",
+      );
+      assertStrictEquals(
+        system2.tagURI,
+        "tag:etaoin.example,1972-12-31:etaoin",
+      );
+    });
+  });
+
+  describe("::taggingEntity", () => {
+    it("[[Get]] returns the tagging entity", () => {
+      const system = new TagSystem("etaoin.example", "1972-12-31");
+      assertStrictEquals(
+        system.taggingEntity,
+        "etaoin.example,1972-12-31",
+      );
+    });
+  });
+});
diff --git a/names.js b/names.js
new file mode 100644 (file)
index 0000000..086e344
--- /dev/null
+++ b/names.js
@@ -0,0 +1,14 @@
+// 📧🏷️ Étiquette ∷ names.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/>.
+
+export const taggingContext =
+  "https://ns.1024.gdn/Tagging/context.jsonld";
+
+export const taggingDiscoveryContext =
+  "https://ns.1024.gdn/Tagging/discovery.context.jsonld";
This page took 0.1788 seconds and 4 git commands to generate.