OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-note / lib / drive-manager.js

240 lines · javascript ref:
/**
 * DriveManager — Per-notebook Hyperdrive lifecycle management
 *
 * Each notebook gets its own Hyperdrive, enabling selective P2P sharing.
 * Registry stores drive index (never replicated). Drives are cached in memory.
 *
 * Uses store.namespace() for local drives (deterministic key per slug) and
 * store.session() for remote drives (opened by key).
 *
 * Registry schema:
 *   notebook-drive:{slug} → { slug, driveKey, discoveryKey, isLocal, isShared, addedAt }
 */

import Hyperdrive from 'hyperdrive'

const DRIVE_PREFIX = 'notebook-drive:'
const DELETED_PREFIX = 'deleted-drive:'

export class DriveManager {
  constructor (store, registry) {
    this.store = store
    this.registry = registry
    this._cache = new Map() // slug → { drive, entry }
    this._allDrives = new Set() // all opened drives (for shutdown)
  }

  /**
   * Create a local notebook drive.
   * Uses store.namespace() so each drive gets its own isolated Corestore session.
   */
  async createDrive (slug, ownerUserId = null) {
    const existing = this._cache.get(slug)
    if (existing) return existing.drive

    const drive = new Hyperdrive(this.store.namespace('notebook-' + slug))
    await drive.ready()
    this._allDrives.add(drive)

    const entry = {
      slug,
      driveKey: drive.key.toString('hex'),
      discoveryKey: drive.discoveryKey.toString('hex'),
      isLocal: true,
      isShared: false,
      ownerUserId,
      addedAt: Date.now()
    }

    await this.registry.put(DRIVE_PREFIX + slug, entry)
    this._cache.set(slug, { drive, entry })
    return drive
  }

  /**
   * Get or open a drive by slug. Opens from cache, registry, or returns null.
   */
  async getDrive (slug) {
    const cached = this._cache.get(slug)
    if (cached) return cached.drive

    const regEntry = await this.registry.get(DRIVE_PREFIX + slug)
    if (!regEntry) return null

    const entry = regEntry.value
    let drive

    if (entry.isLocal) {
      drive = new Hyperdrive(this.store.namespace('notebook-' + slug))
    } else {
      drive = new Hyperdrive(this.store.session(), Buffer.from(entry.driveKey, 'hex'))
    }

    await drive.ready()
    this._allDrives.add(drive)
    this._cache.set(slug, { drive, entry })
    return drive
  }

  /**
   * Remove drive from registry + cache. The drive object stays in _allDrives
   * for cleanup at shutdown (closing a drive during operation can invalidate
   * sibling corestore sessions).
   */
  async deleteDrive (slug) {
    // Record a tombstone BEFORE removing the registry entry, so the orphaned
    // cores stay trackable and reclaimable. We deliberately do NOT purge here:
    // purging/closing a drive mid-operation can invalidate sibling corestore
    // sessions. Reclaim the space later via purgeDeletedDrives() when offline.
    try {
      const regEntry = await this.registry.get(DRIVE_PREFIX + slug)
      if (regEntry && regEntry.value) {
        const e = regEntry.value
        await this.registry.put(DELETED_PREFIX + slug, {
          slug,
          driveKey: e.driveKey || null,
          isLocal: e.isLocal !== false,
          deletedAt: Date.now()
        })
      }
    } catch (err) {
      console.error('[DriveManager] tombstone write failed for ' + slug + ':', err.message)
    }
    this._cache.delete(slug)
    await this.registry.del(DRIVE_PREFIX + slug)
  }

  /**
   * List drives that were deleted (tombstones) but whose cores may still occupy
   * disk. These are candidates for purgeDeletedDrives().
   */
  async listDeletedDrives () {
    const out = []
    for await (const entry of this.registry.createReadStream({
      gt: DELETED_PREFIX,
      lt: DELETED_PREFIX + '\xff'
    })) {
      out.push(entry.value)
    }
    return out
  }

  // NOTE: actual space reclamation (purging the orphaned cores) is a tracked
  // follow-up. Hyperdrive.purge() is broken in the pinned hyperdrive 11.13.4
  // (it calls a non-existent this._closeAllSessions and aborts), so a safe
  // reclaim path needs a hyperdrive upgrade or a lower-level core purge, run as
  // an OFFLINE tool (purging live-referenced cores invalidates sibling
  // sessions). For now deleteDrive TRACKS the orphan via a tombstone so the
  // wasted space is observable (listDeletedDrives) and reclaimable later, rather
  // than silently lost.

  /**
   * List all registered notebook drives.
   */
  async listDrives () {
    const drives = []
    for await (const entry of this.registry.createReadStream({
      gt: DRIVE_PREFIX,
      lt: DRIVE_PREFIX + '\xff'
    })) {
      drives.push(entry.value)
    }
    return drives
  }

  /**
   * List drives owned by a specific user.
   */
  async listDrivesByOwner (userId) {
    const all = await this.listDrives()
    return all.filter(d => d.ownerUserId === userId)
  }

  /**
   * Add a remote notebook drive by hex key.
   */
  async addRemoteDrive (driveKeyHex, slug) {
    const drive = new Hyperdrive(this.store.session(), Buffer.from(driveKeyHex, 'hex'))
    await drive.ready()
    this._allDrives.add(drive)

    const entry = {
      slug,
      driveKey: driveKeyHex,
      discoveryKey: drive.discoveryKey.toString('hex'),
      isLocal: false,
      isShared: false,
      addedAt: Date.now()
    }

    await this.registry.put(DRIVE_PREFIX + slug, entry)
    this._cache.set(slug, { drive, entry })
    return drive
  }

  /**
   * Remove a remote drive from registry + cache.
   */
  async removeRemoteDrive (slug) {
    await this.deleteDrive(slug)
  }

  /**
   * Get drive info from registry.
   */
  async getDriveInfo (slug) {
    const cached = this._cache.get(slug)
    if (cached) return cached.entry

    const regEntry = await this.registry.get(DRIVE_PREFIX + slug)
    return regEntry ? regEntry.value : null
  }

  /**
   * Check if a slug refers to a local drive.
   */
  isLocal (slug) {
    const cached = this._cache.get(slug)
    return cached ? cached.entry.isLocal : true
  }

  /**
   * Check if a slug refers to a remote drive.
   */
  isRemote (slug) {
    const cached = this._cache.get(slug)
    return cached ? !cached.entry.isLocal : false
  }

  /**
   * Close ALL opened drives. Call before store.close() for clean shutdown.
   * This closes every drive ever opened (including ones removed from cache
   * via deleteDrive) to prevent event loop leaks.
   */
  async closeAll () {
    for (const drive of this._allDrives) {
      try { await drive.close() } catch {}
    }
    this._allDrives.clear()
    this._cache.clear()
  }

  /**
   * Update the isShared flag for a drive.
   */
  async setShared (slug, shared) {
    const regEntry = await this.registry.get(DRIVE_PREFIX + slug)
    if (!regEntry) return

    const entry = regEntry.value
    entry.isShared = shared

    await this.registry.put(DRIVE_PREFIX + slug, entry)

    const cached = this._cache.get(slug)
    if (cached) cached.entry = entry
  }
}

export default DriveManager