OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-note commits / 6d6866b

drive-manager: track deleted drives via tombstones (orphan cores reclaimable)
deleteDrive removed the registry entry + cache but left the Hyperdrive's cores on disk untracked forever — silent unbounded growth. (The non-purge is intentional: closing/purging a drive mid-operation invalidates sibling corestore sessions.) - deleteDrive now writes a `deleted-drive:<slug>` tombstone (slug, driveKey, isLocal, deletedAt) BEFORE removing the live entry, so orphaned cores are observable and reclaimable instead of lost. - Add listDeletedDrives(). Space reclamation itself is a tracked follow-up, NOT shipped here: Hyperdrive .purge() is broken in the pinned hyperdrive 11.13.4 (calls a non-existent this._closeAllSessions and SIGABRTs — verified). A safe reclaim needs a hyperdrive upgrade or low-level core purge, run offline. Shipping only the verified-safe tracking rather than a purge that aborts the runtime. Test: tombstone written on delete (live entry removed); listDeletedDrives surfaces every deletion and excludes live drives. 2/2 pass; existing drive-manager suite still 9/9. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
6d6866b233782702e95fd35b0481b5d7d6322965
by plankatron <fleeky@gmail.com> · 2026-06-23T22:19:24.000Z

Changed files (2) +106 -0

lib/drive-manager.js +43 -0
……@@ -14,6 +14,7 @@
1414import Hyperdrive from 'hyperdrive'
1515
1616const DRIVE_PREFIX = 'notebook-drive:'
17const DELETED_PREFIX = 'deleted-drive:'
1718
1819export class DriveManager {
1920 constructor (store, registry) {
……@@ -81,10 +82,52 @@ export class DriveManager {
8182 * sibling corestore sessions).
8283 */
8384 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 }
84103 this._cache.delete(slug)
85104 await this.registry.del(DRIVE_PREFIX + slug)
86105 }
87106
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
88131 /**
89132 * List all registered notebook drives.
90133 */
test/unit/drive-gc.test.js +63 -0
……@@ -0,0 +1,63 @@
1import test from 'brittle'
2import fs from 'bare-fs'
3import path from 'bare-path'
4import os from 'bare-os'
5import Corestore from 'corestore'
6import Hyperbee from 'hyperbee'
7import { DriveManager } from '../../lib/drive-manager.js'
8
9function 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
18function 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
29test('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
47test('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})