Source: state/AbstractStore.js

import { reactive, ref } from 'vue';
import { Observable } from '../utils/NanoHooks.js';
import { noop } from '../utils/function/noop.js';

/**
 * A document is an object with at least
 * an id property and optionally more arbitrary properties.
 * @typedef Document
 * @property id {number} the id of the document
 */

/**
 *
 */
export class AbstractStore {
  constructor({ projectId, key }) {
    this.observable = new Observable();

    /**
     * @type {number}
     */
    this.projectId = projectId;
    /**
     * @type {Reactive<{}>}
     */
    this.entries = reactive({});
    this.size = ref(0);
    /**
     * Namespace key for unique identification
     * @type {string}
     */
    this.key = key;
  }

  entry(id) {
    const entry = this.entries[id];
    if (!entry) {
      throw new Error(
        `Entry not found by id ${id} in ${this.projectId}/${this.key}`
      );
    }
    return entry;
  }

  /**
   * Pass in a callback to observe all changes for add, update and delete.
   * Returns a function to unregister.
   * @param callbacks
   */
  observe(callbacks) {
    const removeAdded = callbacks.added
      ? this.observable.on('added', callbacks.added)
      : noop;
    const removeUpdated = callbacks.updated
      ? this.observable.on('updated', callbacks.updated)
      : noop;
    const removeDeleted = callbacks.removed
      ? this.observable.on('removed', callbacks.removed)
      : noop;
    const removeChanged = callbacks.changed
      ? this.observable.on('changed', callbacks.changed)
      : noop;

    // call on dispose/unmount
    return () => {
      removeAdded();
      removeUpdated();
      removeDeleted();
      removeChanged();
    };
  }

  /**
   * Add documents (objects) of arbitrary length to the store.
   * Known docs are skipped
   * @param docs {Document[]}
   * @return {Document[]}
   */
  add(...docs) {
    const documents = {};
    docs.forEach((doc) => {
      // skip existing docs / avoid duplicates
      if (!(doc.id in this.entries)) {
        documents[doc.id] = doc;
        this.size.value++;
      }
    });
    Object.assign(this.entries, documents);
    const added = Object.values(documents);
    this.observable.run('added', added, this.all());
    this.observable.run('changed', { type: 'added', docs: added });
    return added;
  }

  /**
   * Update a single document or multiple documents.
   * @param docIdOrFn {string|function}
   * @param value {object}
   * @param updateId {boolean=}
   */
  update(docIdOrFn, value, { updateId = false } = {}) {
    if (typeof docIdOrFn === 'function') {
      // nested changes are applied directly by
      // consumer and this reflected in a function
      // that returns all ids of updated docs
      const allDocs = this.all();
      const updatedDocs = docIdOrFn(allDocs);
      this.observable.run('updated', updatedDocs, allDocs);
      this.observable.run('changed', { type: 'updated', docs: updatedDocs });
    } else {
      const entry = this.entries[docIdOrFn];
      const { id, ...values } = value;
      Object.assign(entry, values);
      if (updateId) {
        entry.id = id;
        delete this.entries[docIdOrFn];
        this.entries[id] = entry;
      }
      this.observable.run('updated', [entry], this.all());
      this.observable.run('changed', { type: 'updated', docs: [entry] });
    }
  }

  remove(...ids) {
    // check for existence independent of
    // actual removal operation
    ids.forEach((id) => this.entry(id));
    const docs = [];
    ids.forEach((id) => {
      const entry = this.entries[id];
      docs.push(entry);
      delete this.entries[id];
      this.size.value--;
    });
    this.observable.run('removed', docs);
    this.observable.run('changed', { type: 'removed', docs });
    return docs;
  }

  all() {
    return Object.values(this.entries);
  }

  async save() {
    throw new Error(`Method must be implemented by subclass`);
  }
}