OverviewFilesCommitsIssuesDocsReleasesPeers

pearbrowser-desktop / backend / user-data.js

261 lines · javascript ref:
/**
 * User data — bookmarks, history, settings — stored in a user-scoped Hyperbee.
 *
 * Phase 1 ticket 2 of the Holepunch alignment plan. Replaces AsyncStorage
 * on the RN side. Data lives inside the user's Corestore and replicates
 * across all devices that share the user's root keypair.
 *
 * Design:
 *   - One Hyperbee per data type (bookmarks, history, settings) under
 *     named Hypercores inside the shared Corestore.
 *   - Each device mirrors the user's writer cores via Autobase so writes
 *     from any device converge. (For Phase 1 we use single-writer Hyperbees
 *     per device; multi-device convergence via Autobase is a Phase 4 refinement.)
 *   - Keys are prefixed (bookmarks!url, history!ts, settings!key).
 *   - Values are plain JSON.
 */

const Hyperbee = require('hyperbee')
const b4a = require('b4a')

const MAX_HISTORY = 200
const MAX_BOOKMARKS = 10_000
const MAX_KEY_LENGTH = 2048

class UserData {
  /**
   * @param {Corestore} store — the user-scoped Corestore
   * @param {Hyperswarm} swarm — for replicating across devices
   * @param {object} opts
   */
  constructor (store, swarm, opts = {}) {
    if (!store) throw new Error('UserData requires a Corestore')
    this.store = store
    this.swarm = swarm
    this._ready = false
    this._bees = null
    this._onReady = []
  }

  async ready () {
    if (this._ready) return
    // One namespaced Hyperbee per data type
    const core = (name) => this.store.get({ name: `pearbrowser-userdata-${name}` })
    const bee = (name) => new Hyperbee(core(name), {
      keyEncoding: 'utf-8',
      valueEncoding: 'json',
    })

    this._bees = {
      bookmarks: bee('bookmarks'),
      history: bee('history'),
      settings: bee('settings'),
      session: bee('session'),
      tabs: bee('tabs'),
    }

    await Promise.all(Object.values(this._bees).map((b) => b.ready()))
    this._ready = true

    // Advertise our cores over swarm so other devices can replicate
    if (this.swarm && typeof this.swarm.join === 'function') {
      for (const bee of Object.values(this._bees)) {
        try {
          const topic = bee.core.discoveryKey
          if (topic) this.swarm.join(topic, { server: true, client: true })
        } catch (err) {
          console.warn('[UserData] swarm join failed:', err && err.message)
        }
      }
    }
  }

  _requireReady () {
    if (!this._ready) throw new Error('UserData not ready — call ready() first')
  }

  _validateKey (key) {
    if (typeof key !== 'string') throw new Error('key must be a string')
    if (key.length === 0 || key.length > MAX_KEY_LENGTH) throw new Error('key length out of range')
    if (key.includes('\x00')) throw new Error('key contains null byte')
  }

  // --- Bookmarks ---

  async listBookmarks () {
    this._requireReady()
    const out = []
    for await (const entry of this._bees.bookmarks.createReadStream({ reverse: true })) {
      out.push(entry.value)
    }
    return out
  }

  async addBookmark ({ url, title }) {
    this._requireReady()
    this._validateKey(url)
    // Sort by most-recent-first using inverted timestamp prefix
    const addedAt = Date.now()
    const value = { url, title: String(title || ''), addedAt }
    // Use (max - addedAt) so reverse-lex order == newest first
    const sortKey = String(Number.MAX_SAFE_INTEGER - addedAt).padStart(18, '0')
    await this._bees.bookmarks.put(`bm!${sortKey}!${url}`, value)
    return value
  }

  async removeBookmark (url) {
    this._requireReady()
    this._validateKey(url)
    // Scan and delete all keys matching the url — url can appear under any sortKey
    const toDelete = []
    for await (const entry of this._bees.bookmarks.createReadStream({ gte: 'bm!', lt: 'bm!~' })) {
      if (entry.value && entry.value.url === url) toDelete.push(entry.key)
    }
    for (const key of toDelete) await this._bees.bookmarks.del(key)
    return toDelete.length
  }

  // --- History ---

  async listHistory ({ limit = 200 } = {}) {
    this._requireReady()
    const out = []
    let count = 0
    for await (const entry of this._bees.history.createReadStream({ reverse: true })) {
      out.push(entry.value)
      if (++count >= limit) break
    }
    return out
  }

  async addHistory ({ url, title }) {
    this._requireReady()
    this._validateKey(url)
    const visitedAt = Date.now()
    const sortKey = String(Number.MAX_SAFE_INTEGER - visitedAt).padStart(18, '0')
    // Remove previous entries for same url (Hyperbee del is cheap)
    for await (const entry of this._bees.history.createReadStream({ gte: 'h!', lt: 'h!~' })) {
      if (entry.value && entry.value.url === url) {
        await this._bees.history.del(entry.key)
      }
    }
    await this._bees.history.put(`h!${sortKey}!${url}`, { url, title: String(title || ''), visitedAt })
    // Trim to MAX_HISTORY — walk from oldest and delete overflow
    let total = 0
    const overflow = []
    for await (const entry of this._bees.history.createReadStream({ reverse: true })) {
      if (++total > MAX_HISTORY) overflow.push(entry.key)
    }
    for (const key of overflow) await this._bees.history.del(key)
  }

  async clearHistory () {
    this._requireReady()
    const keys = []
    for await (const entry of this._bees.history.createReadStream({ gte: 'h!', lt: 'h!~' })) {
      keys.push(entry.key)
    }
    for (const key of keys) await this._bees.history.del(key)
    return keys.length
  }

  // --- Settings (key-value) ---

  async getSettings () {
    this._requireReady()
    const out = {}
    for await (const entry of this._bees.settings.createReadStream({ gte: 's!', lt: 's!~' })) {
      const k = entry.key.slice(2) // strip 's!' prefix
      out[k] = entry.value
    }
    return out
  }

  async setSettings (updates) {
    this._requireReady()
    if (!updates || typeof updates !== 'object') return {}
    for (const [k, v] of Object.entries(updates)) {
      if (k === '__proto__' || k === 'constructor' || k === 'prototype') continue
      await this._bees.settings.put(`s!${k}`, v)
    }
    return this.getSettings()
  }

  // --- Session (single KV) ---

  async getSession () {
    this._requireReady()
    const entry = await this._bees.session.get('current')
    return entry ? entry.value : null
  }

  async saveSession (state) {
    this._requireReady()
    await this._bees.session.put('current', state)
  }

  // --- Tabs ---

  async getTabs () {
    this._requireReady()
    const entry = await this._bees.tabs.get('current')
    return entry ? entry.value : []
  }

  async saveTabs (tabs) {
    this._requireReady()
    await this._bees.tabs.put('current', Array.isArray(tabs) ? tabs : [])
  }

  // --- Bulk (for AsyncStorage → Hyperbee migration) ---

  async importDump (dump) {
    this._requireReady()
    let imported = 0
    if (dump && typeof dump === 'object') {
      if (Array.isArray(dump.bookmarks)) {
        for (const b of dump.bookmarks) {
          if (b && b.url) { await this.addBookmark({ url: b.url, title: b.title || '' }); imported++ }
        }
      }
      if (Array.isArray(dump.history)) {
        for (const h of dump.history) {
          if (h && h.url) { await this.addHistory({ url: h.url, title: h.title || '' }); imported++ }
        }
      }
      if (dump.settings && typeof dump.settings === 'object') {
        await this.setSettings(dump.settings); imported++
      }
      if (dump.session) {
        await this.saveSession(dump.session); imported++
      }
      if (Array.isArray(dump.tabs)) {
        await this.saveTabs(dump.tabs); imported++
      }
    }
    return imported
  }

  async exportAll () {
    this._requireReady()
    return {
      bookmarks: await this.listBookmarks(),
      history: await this.listHistory({ limit: MAX_HISTORY }),
      settings: await this.getSettings(),
      session: await this.getSession(),
      tabs: await this.getTabs(),
    }
  }

  async close () {
    if (!this._bees) return
    for (const bee of Object.values(this._bees)) {
      try { await bee.close() } catch (_) {}
    }
    this._bees = null
    this._ready = false
  }
}

module.exports = { UserData, MAX_HISTORY, MAX_BOOKMARKS }