/**
* 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