OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-note commits / 76b052a

Emit media-gc events from notebookApply for push-based orphan cleanup
notebookApply now collects GC events during delete-note and remove-media operations, emitting them on the Autobase after batch flush. HypernoteServer wires listeners on all SharedNotebook Autobases (existing and future) to route media-removed hashes to MediaStore.scheduleOrphan(). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
76b052a619ee36ca3586aa77578c9d3d815bc2c1
by plankatron <fleeky@gmail.com> · 2026-03-19T16:33:09.000Z

Changed files (2) +168 -0

hypernote-server.js +157 -0
……@@ -329,6 +329,9 @@ class HypernoteServer {
329329 // Run migration from single-drive to per-notebook drives
330330 await this._migrateToPerNotebookDrives()
331331
332 // Run migration from Hyperdrive media to content-addressed MediaStore
333 await this._migrateToContentAddressedMedia()
334
332335 // Ensure device keypair exists
333336 const isNew = await this.identityManager.ensureKeypair()
334337 if (isNew) {
……@@ -370,6 +373,9 @@ class HypernoteServer {
370373 this.authManager.sharedRegistry = this.multiwriter.registry
371374 this.noteManager.multiwriter = this.multiwriter
372375
376 // Wire media-gc events from all existing shared notebooks
377 this._wireNotebookGcEvents()
378
373379 const mwStatus = await this.multiwriter.getStatus()
374380 const mwKeyHex = mwStatus.registryKey ? (Buffer.isBuffer(mwStatus.registryKey) ? mwStatus.registryKey.toString('hex') : mwStatus.registryKey) : null
375381 const localWriterKey = this.multiwriter.registry ? this.multiwriter.registry.localWriterKey : null
……@@ -582,6 +588,45 @@ class HypernoteServer {
582588 return false
583589 }
584590
591 /**
592 * Wire media-gc event listeners from all current shared notebooks.
593 * Also patches MultiwriterManager.shareNotebook to auto-wire future notebooks.
594 */
595 _wireNotebookGcEvents () {
596 if (!this.multiwriter || !this.multiwriter._sharedNotebooks) return
597
598 // Wire existing notebooks
599 for (const [slug, snb] of this.multiwriter._sharedNotebooks) {
600 this._wireGcForNotebook(slug, snb)
601 }
602
603 // Patch shareNotebook to wire GC for newly created notebooks
604 const origShareNotebook = this.multiwriter.shareNotebook.bind(this.multiwriter)
605 this.multiwriter.shareNotebook = async (slug) => {
606 const snb = await origShareNotebook(slug)
607 this._wireGcForNotebook(slug, snb)
608 return snb
609 }
610 }
611
612 /**
613 * Attach a media-gc listener to a SharedNotebook's Autobase.
614 * When a note is deleted or media is removed, schedule orphan cleanup.
615 */
616 _wireGcForNotebook (slug, snb) {
617 if (!snb || !snb._base || !snb._base.on) return
618 if (snb._gcWired) return // prevent duplicate listeners
619 snb._gcWired = true
620
621 snb._base.on('media-gc', (events) => {
622 for (const event of events) {
623 if (event.hash && this.mediaStore) {
624 this.mediaStore.scheduleOrphan(event.hash)
625 }
626 }
627 })
628 }
629
585630 /**
586631 * Migrate from Phase 1-5 single-drive layout to per-notebook drives.
587632 */
……@@ -632,6 +677,118 @@ class HypernoteServer {
632677 await this.registry.put('migration:v2-drives', { migratedAt: Date.now(), count: slugs.length })
633678 }
634679
680 /**
681 * Recursively copy a directory tree using bare-fs.
682 */
683 async _copyDir (src, dest) {
684 await fs.promises.mkdir(dest, { recursive: true })
685 const entries = await fs.promises.readdir(src, { withFileTypes: true })
686 for (const entry of entries) {
687 const srcPath = path.join(src, entry.name)
688 const destPath = path.join(dest, entry.name)
689 if (entry.isDirectory()) {
690 await this._copyDir(srcPath, destPath)
691 } else {
692 await fs.promises.copyFile(srcPath, destPath)
693 }
694 }
695 }
696
697 /**
698 * Migrate Hyperdrive /media/ files to content-addressed MediaStore.
699 * Walks all local drives, hashes each file, stores in MediaStore,
700 * and creates legacy filename→hash mappings for URL backward compat.
701 */
702 async _migrateToContentAddressedMedia () {
703 const marker = await this.registry.get('migration:v3-media-store')
704 if (marker) return
705
706 // Backup storage before migration
707 const backupPath = this.storage + '.pre-v3-backup'
708 try {
709 await fs.promises.access(backupPath)
710 // Backup already exists (previous failed attempt?)
711 console.log('[Migration] Backup already exists at', backupPath)
712 } catch {
713 console.log('[Migration] Backing up storage before v3 media migration...')
714 try {
715 await this._copyDir(this.storage, backupPath)
716 console.log('[Migration] Backup saved to', backupPath)
717 } catch (err) {
718 console.error('[Migration] Backup failed:', err.message)
719 console.error('[Migration] Aborting migration — please back up manually')
720 return
721 }
722 }
723
724 let migrated = 0
725 let deduped = 0
726 let failed = 0
727
728 const mimeTypes = {
729 png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg',
730 gif: 'image/gif', webp: 'image/webp', svg: 'image/svg+xml',
731 mp4: 'video/mp4', webm: 'video/webm', mov: 'video/quicktime',
732 mp3: 'audio/mpeg', ogg: 'audio/ogg'
733 }
734
735 // Walk all local Hyperdrive /media/ files
736 const drives = await this.driveManager.listDrives()
737 for (const entry of drives) {
738 if (!entry.isLocal) continue
739 const drive = await this.driveManager.getDrive(entry.slug)
740 if (!drive) continue
741
742 let files
743 try {
744 const iter = drive.readdir('/media/')
745 files = []
746 for await (const f of iter) files.push(f)
747 } catch {
748 continue // no /media/ dir
749 }
750
751 for (const filename of files) {
752 try {
753 const buf = await drive.get('/media/' + filename)
754 if (!buf || buf.length === 0) continue
755
756 const ext = filename.split('.').pop().toLowerCase()
757 const mimeType = mimeTypes[ext] || 'application/octet-stream'
758
759 const hash = this.mediaStore._hash(buf)
760 const existed = await this.mediaStore.has(hash)
761 await this.mediaStore.put(buf, mimeType)
762
763 if (existed) {
764 deduped++
765 } else {
766 migrated++
767 }
768
769 // Store legacy filename → hash mapping for URL backward compat
770 await this.mediaStore._index.put('legacy:' + filename, {
771 hash,
772 slug: entry.slug,
773 migratedAt: Date.now()
774 })
775 } catch (err) {
776 console.error('[Migration] Failed to migrate', entry.slug + '/' + filename + ':', err.message)
777 failed++
778 }
779 }
780 }
781
782 await this.registry.put('migration:v3-media-store', {
783 migratedAt: Date.now(),
784 migrated,
785 deduped,
786 failed
787 })
788
789 console.log('[Migration] Media migration complete: ' + migrated + ' migrated, ' + deduped + ' deduped, ' + failed + ' failed')
790 }
791
635792 /**
636793 * Ensure default notebook and profile exist.
637794 */
lib/multiwriter/notebook-apply.js +11 -0
……@@ -16,6 +16,7 @@ export function decodeOp (buf) {
1616
1717export async function notebookApply (batch, view, base) {
1818 const b = view.batch()
19 const gcEvents = []
1920
2021 for (const node of batch) {
2122 const op = decodeOp(node.value)
……@@ -63,6 +64,8 @@ export async function notebookApply (batch, view, base) {
6364 }
6465
6566 case 'delete-note': {
67 // Collect noteId for GC — content will be scanned by MediaStore
68 gcEvents.push({ noteId: op.noteId, type: 'note-deleted' })
6669 await b.del(`note:${op.noteId}`)
6770 break
6871 }
……@@ -111,6 +114,7 @@ export async function notebookApply (batch, view, base) {
111114 }
112115
113116 case 'remove-media': {
117 gcEvents.push({ hash: op.hash, type: 'media-removed' })
114118 await b.del(`media:${op.noteId}:${op.hash}`)
115119 break
116120 }
……@@ -118,6 +122,13 @@ export async function notebookApply (batch, view, base) {
118122 }
119123
120124 await b.flush()
125
126 // Emit GC events for push-based garbage collection
127 if (gcEvents.length > 0 && base.emit) {
128 try {
129 base.emit('media-gc', gcEvents)
130 } catch {}
131 }
121132}
122133
123134/**