OverviewFilesCommitsIssuesDocsReleasesPeers

keet-daemon / lib / room-meta.js

164 lines · javascript ref:
/**
 * room-meta.js — Room metadata backed by MetadataBase (Autobase)
 *
 * Thin wrapper over MetadataBase that provides the room-specific
 * interface consumed by topic-announce.js, registry-room.js, and router.js.
 *
 * The MetadataBase Autobase handles:
 *   - Multi-writer conflict resolution (field-level LWW)
 *   - Replication via Hyperswarm + Protomux writer exchange
 *   - Persistent state in Hyperbee view
 *   - Secondary indexes (topic search)
 *
 * This module provides:
 *   - Backward-compatible get/set/getAll interface
 *   - parseHsMessage/createHsMessage helpers (used by dm-shares, registry-room)
 *   - One-time migration from legacy JSON cache
 *
 * Usage:
 *   const meta = new RoomMeta(metadataBase)
 *   await meta.get(roomId)    → { topics: [...], discoverable: true, ... }
 *   await meta.set(roomId, { topics: ["rust"], discoverable: true })
 *   await meta.getAll()       → { roomId: { ... }, ... }
 */

import fs from 'bare-fs'
import path from 'bare-path'
import os from 'bare-os'

const HS_PREFIX = '{"_hs":'

export class RoomMeta {
  /**
   * @param {import('./metadata-base.js').MetadataBase} metadataBase
   * @param {object} [opts]
   * @param {string} [opts.storagePath] - For legacy migration (reads old JSON cache)
   */
  constructor (metadataBase, opts = {}) {
    this._base = metadataBase
    this._migrated = false

    // Legacy path (for JSON cache migration only)
    if (opts.storagePath) {
      const configDir = path.join(opts.storagePath, '.config', 'keet-cli')
      this._legacyFilePath = path.join(configDir, 'room-meta.json')
    } else {
      const home = os.homedir() || '/tmp'
      const configDir = path.join(home, '.config', 'keet-cli')
      this._legacyFilePath = path.join(configDir, 'room-meta.json')
    }
  }

  /**
   * Get room metadata.
   * Returns metadata object, or {} if no metadata exists.
   * @param {string} roomId
   * @returns {Promise<object>}
   */
  async get (roomId) {
    const value = await this._base.get('room', roomId)
    if (!value) return {}
    // Strip internal fields from public interface
    const { _by, _ts, _fieldTs, _ns, _raw, ...fields } = value
    return fields
  }

  /**
   * Set room metadata. Merges with existing (field-level LWW in Autobase).
   * @param {string} roomId
   * @param {object} metadata - Fields to set (null values delete the field)
   * @returns {Promise<object>} The current metadata after set
   */
  async set (roomId, metadata) {
    await this._base.set('room', roomId, metadata)
    return this.get(roomId)
  }

  /**
   * Get all room metadata.
   * @returns {Promise<object>} Map of roomId → metadata
   */
  async getAll () {
    const raw = await this._base.getAll('room')
    const result = {}
    for (const [id, value] of Object.entries(raw)) {
      const { _by, _ts, _fieldTs, _ns, _raw, ...fields } = value
      result[id] = fields
    }
    return result
  }

  /**
   * Search rooms by topic (uses Autobase secondary index).
   * @param {string} topic
   * @returns {Promise<Array<{roomId, topic}>>}
   */
  async searchByTopic (topic) {
    return this._base.searchByTopic(topic)
  }

  /**
   * Migrate legacy JSON cache into MetadataBase.
   * Called once on first startup after upgrade. Reads the old
   * room-meta.json file and imports all entries into the Autobase.
   */
  async migrateLegacy () {
    if (this._migrated) return { migrated: 0 }

    let count = 0

    // Migrate from local JSON cache
    try {
      const data = fs.readFileSync(this._legacyFilePath, 'utf8')
      const store = JSON.parse(data)

      for (const [roomId, meta] of Object.entries(store)) {
        // Strip legacy internal fields
        const { _syncTs, ...fields } = meta
        if (Object.keys(fields).length > 0) {
          await this._base.set('room', roomId, fields)
          count++
        }
      }

      if (count > 0) {
        // Rename legacy file to mark migration complete
        const backupPath = this._legacyFilePath + '.migrated'
        try {
          fs.renameSync(this._legacyFilePath, backupPath)
        } catch {
          // Not fatal — may fail on some platforms
        }
        console.log(`[room-meta] Migrated ${count} rooms from legacy JSON cache`)
      }
    } catch {
      // No legacy file — nothing to migrate
    }

    this._migrated = true
    return { migrated: count }
  }
}

/**
 * Parse any _hs message from text. Returns { _hs, ...fields } or null.
 * The _hs field contains the message type (e.g. "room-listing", "room-share").
 */
export function parseHsMessage (text) {
  if (!text || !text.startsWith(HS_PREFIX)) return null
  try {
    const parsed = JSON.parse(text)
    if (parsed._hs) return parsed
  } catch {
    // Not valid JSON
  }
  return null
}

/**
 * Create an _hs message string.
 */
export function createHsMessage (type, fields) {
  return JSON.stringify({ _hs: type, ...fields })
}