| … | … | @@ -14,6 +14,7 @@ |
| 14 | 14 | import Hyperdrive from 'hyperdrive' |
| 15 | 15 | |
| 16 | 16 | const DRIVE_PREFIX = 'notebook-drive:' |
| 17 | const DELETED_PREFIX = 'deleted-drive:' | |
| 17 | 18 | |
| 18 | 19 | export class DriveManager { |
| 19 | 20 | constructor (store, registry) { |
| … | … | @@ -81,10 +82,52 @@ export class DriveManager { |
| 81 | 82 | * sibling corestore sessions). |
| 82 | 83 | */ |
| 83 | 84 | async deleteDrive (slug) { |
| 85 | // Record a tombstone BEFORE removing the registry entry, so the orphaned | |
| 86 | // cores stay trackable and reclaimable. We deliberately do NOT purge here: | |
| 87 | // purging/closing a drive mid-operation can invalidate sibling corestore | |
| 88 | // sessions. Reclaim the space later via purgeDeletedDrives() when offline. | |
| 89 | try { | |
| 90 | const regEntry = await this.registry.get(DRIVE_PREFIX + slug) | |
| 91 | if (regEntry && regEntry.value) { | |
| 92 | const e = regEntry.value | |
| 93 | await this.registry.put(DELETED_PREFIX + slug, { | |
| 94 | slug, | |
| 95 | driveKey: e.driveKey || null, | |
| 96 | isLocal: e.isLocal !== false, | |
| 97 | deletedAt: Date.now() | |
| 98 | }) | |
| 99 | } | |
| 100 | } catch (err) { | |
| 101 | console.error('[DriveManager] tombstone write failed for ' + slug + ':', err.message) | |
| 102 | } | |
| 84 | 103 | this._cache.delete(slug) |
| 85 | 104 | await this.registry.del(DRIVE_PREFIX + slug) |
| 86 | 105 | } |
| 87 | 106 | |
| 107 | /** | |
| 108 | * List drives that were deleted (tombstones) but whose cores may still occupy | |
| 109 | * disk. These are candidates for purgeDeletedDrives(). | |
| 110 | */ | |
| 111 | async listDeletedDrives () { | |
| 112 | const out = [] | |
| 113 | for await (const entry of this.registry.createReadStream({ | |
| 114 | gt: DELETED_PREFIX, | |
| 115 | lt: DELETED_PREFIX + '\xff' | |
| 116 | })) { | |
| 117 | out.push(entry.value) | |
| 118 | } | |
| 119 | return out | |
| 120 | } | |
| 121 | ||
| 122 | // NOTE: actual space reclamation (purging the orphaned cores) is a tracked | |
| 123 | // follow-up. Hyperdrive.purge() is broken in the pinned hyperdrive 11.13.4 | |
| 124 | // (it calls a non-existent this._closeAllSessions and aborts), so a safe | |
| 125 | // reclaim path needs a hyperdrive upgrade or a lower-level core purge, run as | |
| 126 | // an OFFLINE tool (purging live-referenced cores invalidates sibling | |
| 127 | // sessions). For now deleteDrive TRACKS the orphan via a tombstone so the | |
| 128 | // wasted space is observable (listDeletedDrives) and reclaimable later, rather | |
| 129 | // than silently lost. | |
| 130 | ||
| 88 | 131 | /** |
| 89 | 132 | * List all registered notebook drives. |
| 90 | 133 | */ |
| … | … | @@ -0,0 +1,63 @@ |
| 1 | import test from 'brittle' | |
| 2 | import fs from 'bare-fs' | |
| 3 | import path from 'bare-path' | |
| 4 | import os from 'bare-os' | |
| 5 | import Corestore from 'corestore' | |
| 6 | import Hyperbee from 'hyperbee' | |
| 7 | import { DriveManager } from '../../lib/drive-manager.js' | |
| 8 | ||
| 9 | function tmpSetup () { | |
| 10 | const dir = path.join(os.tmpdir(), 'hn-dgc-' + Date.now() + '-' + Math.random().toString(36).slice(2)) | |
| 11 | fs.mkdirSync(dir, { recursive: true }) | |
| 12 | const store = new Corestore(dir) | |
| 13 | const registry = new Hyperbee(store.get({ name: 'registry' }), { keyEncoding: 'utf-8', valueEncoding: 'json' }) | |
| 14 | const dm = new DriveManager(store, registry) | |
| 15 | return { store, registry, dm, dir } | |
| 16 | } | |
| 17 | ||
| 18 | function rmrf (dir) { | |
| 19 | try { | |
| 20 | for (const f of fs.readdirSync(dir)) { | |
| 21 | const fp = path.join(dir, f) | |
| 22 | if (fs.statSync(fp).isDirectory()) rmrf(fp) | |
| 23 | else fs.unlinkSync(fp) | |
| 24 | } | |
| 25 | fs.rmdirSync(dir) | |
| 26 | } catch {} | |
| 27 | } | |
| 28 | ||
| 29 | test('deleteDrive — writes a tombstone so the orphaned cores stay trackable', async (t) => { | |
| 30 | const { registry, dm, dir } = tmpSetup() | |
| 31 | await registry.ready() | |
| 32 | ||
| 33 | await dm.createDrive('gone') | |
| 34 | await dm.deleteDrive('gone') | |
| 35 | ||
| 36 | t.absent((await registry.get('notebook-drive:gone')), 'live registry entry removed') | |
| 37 | const tomb = await registry.get('deleted-drive:gone') | |
| 38 | t.ok(tomb, 'tombstone written') | |
| 39 | t.is(tomb.value.slug, 'gone') | |
| 40 | t.is(tomb.value.isLocal, true) | |
| 41 | t.ok(typeof tomb.value.deletedAt === 'number', 'records deletedAt') | |
| 42 | ||
| 43 | await dm.closeAll() | |
| 44 | rmrf(dir) | |
| 45 | }) | |
| 46 | ||
| 47 | test('listDeletedDrives — surfaces every deleted drive (no longer silently orphaned)', async (t) => { | |
| 48 | const { registry, dm, dir } = tmpSetup() | |
| 49 | await registry.ready() | |
| 50 | ||
| 51 | await dm.createDrive('a') | |
| 52 | await dm.createDrive('b') | |
| 53 | await dm.createDrive('keep') | |
| 54 | await dm.deleteDrive('a') | |
| 55 | await dm.deleteDrive('b') | |
| 56 | ||
| 57 | const deleted = await dm.listDeletedDrives() | |
| 58 | const slugs = deleted.map(d => d.slug).sort() | |
| 59 | t.alike(slugs, ['a', 'b'], 'both deletions tracked, live "keep" excluded') | |
| 60 | ||
| 61 | await dm.closeAll() | |
| 62 | rmrf(dir) | |
| 63 | }) |