fireproof.js

import { put, get, getAll, eventsSince } from './prolly.js'
import TransactionBlockstore, { doTransaction } from './blockstore.js'

// const sleep = ms => new Promise(resolve => setTimeout(resolve, ms))

/**
 * @class Fireproof
 * @classdesc Fireproof stores data in IndexedDB and provides a Merkle clock.
 *  This is the main class for saving and loading JSON and other documents with the database. You can find additional examples and
 *  usage guides in the repository README.
 *
 * @param {Blockstore} blocks - The block storage instance to use documents and indexes
 * @param {CID[]} clock - The Merkle clock head to use for the Fireproof instance.
 * @param {object} [config] - Optional configuration options for the Fireproof instance.
 * @param {object} [authCtx] - Optional authorization context object to use for any authentication checks.
 *
 */
export default class Fireproof {
  #listeners = new Set()

  /**
   * @function storage
   * @memberof Fireproof
   * Creates a new Fireproof instance with default storage settings
   * Most apps should use this and not worry about the details.
   * @static
   * @returns {Fireproof} - a new Fireproof instance
   */
  static storage = (name) => {
    return new Fireproof(new TransactionBlockstore(name), [], { name })
  }

  constructor (blocks, clock, config, authCtx = {}) {
    this.name = config?.name || 'global'
    this.instanceId = `fp.${this.name}.${Math.random().toString(36).substring(2, 7)}`
    this.blocks = blocks
    this.clock = clock
    this.config = config
    this.authCtx = authCtx
  }

  /**
   * Returns a snapshot of the current Fireproof instance as a new instance.
   * @function snapshot
   * @param {CID[]} clock - The Merkle clock head to use for the snapshot.
   * @returns {Fireproof}
   *    A new Fireproof instance representing the snapshot.
   * @memberof Fireproof
   * @instance
   */
  snapshot (clock) {
    // how to handle listeners, views, and config?
    // todo needs a test for listeners, views, and config
    return new Fireproof(this.blocks, clock || this.clock)
  }

  /**
   * Move the current instance to a new point in time. This triggers a notification to all listeners
   * of the Fireproof instance so they can repaint UI, etc.
   * @param {CID[] } clock
   *    Clock to use for the snapshot.
   * @returns {Promise<void>}
   * @memberof Fireproof
   * @instance
   */
  async setClock (clock) {
    // console.log('setClock', this.instanceId, clock)
    this.clock = clock.map((item) => (item['/'] ? item['/'] : item))
    await this.#notifyListeners({ reset: true, clock })
  }

  /**
   * Renders the Fireproof instance as a JSON object.
   * @returns {Object} - The JSON representation of the Fireproof instance. Includes clock heads for the database and its indexes.
   * @memberof Fireproof
   * @instance
   */
  toJSON () {
    // todo this also needs to return the index roots...
    return { clock: this.clock }
  }

  /**
   * Returns the changes made to the Fireproof instance since the specified event.
   * @function changesSince
   * @param {CID[]} [event] - The clock head to retrieve changes since. If null or undefined, retrieves all changes.
   * @returns {Object<{rows : Object[], clock: CID[]}>} An object containing the rows and the head of the instance's clock.
   * @memberof Fireproof
   * @instance
   */
  async changesSince (event) {
    // console.log('changesSince', this.instanceId, event, this.clock)
    let rows, dataCIDs, clockCIDs
    if (event) {
      const resp = await eventsSince(this.blocks, this.clock, event)
      const docsMap = new Map()
      for (const { key, type, value } of resp.result) {
        if (type === 'del') {
          docsMap.set(key, { key, del: true })
        } else {
          docsMap.set(key, { key, value })
        }
      }
      rows = Array.from(docsMap.values())
      clockCIDs = resp.cids
      // console.log('change rows', this.instanceId, rows)
    } else {
      const allResp = await getAll(this.blocks, this.clock)
      rows = allResp.result.map(({ key, value }) => ({ key, value }))
      dataCIDs = allResp.cids
      // console.log('dbdoc rows', this.instanceId, rows)
    }
    return {
      rows,
      clock: this.clock,
      proof: { data: await cidsToProof(dataCIDs), clock: await cidsToProof(clockCIDs) }
    }
  }

  /**
   * Registers a Listener to be called when the Fireproof instance's clock is updated.
   * Recieves live changes from the database after they are committed.
   * @param {Function} listener - The listener to be called when the clock is updated.
   * @returns {Function} - A function that can be called to unregister the listener.
   * @memberof Fireproof
   */
  registerListener (listener) {
    this.#listeners.add(listener)
    return () => {
      this.#listeners.delete(listener)
    }
  }

  async #notifyListeners (changes) {
    // await sleep(0)
    for (const listener of this.#listeners) {
      await listener(changes)
    }
  }

  /**
   * Runs validation on the specified document using the Fireproof instance's configuration. Throws an error if the document is invalid.
   *
   * @param {Object} doc - The document to validate.
   * @returns {Promise<void>}
   * @throws {Error} - Throws an error if the document is invalid.
   * @memberof Fireproof
   * @instance
   */
  async #runValidation (doc) {
    if (this.config && this.config.validateChange) {
      const oldDoc = await this.get(doc._id)
        .then((doc) => doc)
        .catch(() => ({}))
      this.config.validateChange(doc, oldDoc, this.authCtx)
    }
  }

  /**
   * Adds a new document to the database, or updates an existing document. Returns the ID of the document and the new clock head.
   *
   * @param {Object} doc - the document to be added
   * @param {string} doc._id - the document ID. If not provided, a random ID will be generated.
   * @param {Object} doc.* - the document data to be added
   * @returns {Object<{ id: string, clock: CID[]  }>} - The result of adding the document to the database
   * @memberof Fireproof
   * @instance
   */
  async put ({ _id, _proof, ...doc }) {
    const id = _id || 'f' + Math.random().toString(36).slice(2)
    await this.#runValidation({ _id: id, ...doc })
    return await this.#putToProllyTree({ key: id, value: doc }, doc._clock)
  }

  /**
   * Deletes a document from the database
   * @param {string} id - the document ID
   * @returns {Object<{ id: string, clock: CID[] }>} - The result of deleting the document from the database
   * @memberof Fireproof
   * @instance
   */
  async del (docOrId) {
    let id
    let clock = null
    if (docOrId._id) {
      id = docOrId._id
      clock = docOrId._clock
    } else {
      id = docOrId
    }
    await this.#runValidation({ _id: id, _deleted: true })
    // return await this.#putToProllyTree({ key: id, del: true }) // not working at prolly tree layer?
    // this tombstone is temporary until we can get the prolly tree to delete
    return await this.#putToProllyTree({ key: id, value: null }, clock)
  }

  /**
   * Updates the underlying storage with the specified event.
   * @private
   * @param {Object<{key : string, value: any}>} event - the event to add
   * @returns {Object<{ id: string, clock: CID[] }>} - The result of adding the event to storage
   */
  async #putToProllyTree (event, clock = null) {
    if (clock && JSON.stringify(clock) !== JSON.stringify(this.clock)) {
      // we need to check and see what version of the document exists at the clock specified
      // if it is the same as the one we are trying to put, then we can proceed
      const resp = await eventsSince(this.blocks, this.clock, event.value._clock)
      const missedChange = resp.result.find(({ key }) => key === event.key)
      if (missedChange) {
        throw new Error('MVCC conflict, document is changed, please reload the document and try again.')
      }
    }
    const result = await doTransaction(
      '#putToProllyTree',
      this.blocks,
      async (blocks) => await put(blocks, this.clock, event)
    )
    if (!result) {
      console.error('failed', event)
      throw new Error('failed to put at storage layer')
    }
    this.clock = result.head // do we want to do this as a finally block
    await this.#notifyListeners([event])
    return {
      id: event.key,
      clock: this.clock,
      proof: { data: await cidsToProof(result.cids), clock: await cidsToProof(result.clockCIDs) }
    }
    // todo should include additions (or split clock)
  }

  //   /**
  //    * Advances the clock to the specified event and updates the root CID
  //    *   Will be used by replication
  //    */
  //     async advance (event) {
  //       this.clock = await advance(this.blocks, this.clock, event)
  //       this.rootCid = await root(this.blocks, this.clock)
  //       return this.clock
  //     }

  /**
   * Displays a visualization of the current clock in the console
   */
  //   async visClock () {
  //     const shortLink = (l) => `${String(l).slice(0, 4)}..${String(l).slice(-4)}`
  //     const renderNodeLabel = (event) => {
  //       return event.value.data.type === 'put'
  //         ? `${shortLink(event.cid)}\\nput(${shortLink(event.value.data.key)},
  //         {${Object.values(event.value.data.value)}})`
  //         : `${shortLink(event.cid)}\\ndel(${event.value.data.key})`
  //     }
  //     for await (const line of vis(this.blocks, this.clock, { renderNodeLabel })) console.log(line)
  //   }

  /**
   * Retrieves the document with the specified ID from the database
   *
   * @param {string} key - the ID of the document to retrieve
   * @param {Object} [opts] - options
   * @returns {Object<{_id: string, ...doc: Object}>} - the document with the specified ID
   * @memberof Fireproof
   * @instance
   */
  async get (key, opts = {}) {
    const clock = opts.clock || this.clock
    const resp = await get(this.blocks, clock, key)

    // this tombstone is temporary until we can get the prolly tree to delete
    if (!resp || resp.result === null) {
      throw new Error('Not found')
    }
    const doc = resp.result
    if (opts.mvcc === true) {
      doc._clock = this.clock
    }
    doc._proof = {
      data: await cidsToProof(resp.cids),
      clock: this.clock
    }
    doc._id = key
    return doc
  }

  setCarUploader (carUploaderFn) {
    console.log('registering car uploader')
    // https://en.wikipedia.org/wiki/Law_of_Demeter - this is a violation of the law of demeter
    this.blocks.valet.uploadFunction = carUploaderFn
  }

  setRemoteBlockReader (remoteBlockReaderFn) {
    // console.log('registering remote block reader')
    this.blocks.valet.remoteBlockFunction = remoteBlockReaderFn
  }
}

export async function cidsToProof (cids) {
  if (!cids || !cids.all) return []
  const all = await cids.all()
  return [...all].map((cid) => cid.toString())
}