| … | … | @@ -329,6 +329,9 @@ class HypernoteServer { |
| 329 | 329 | // Run migration from single-drive to per-notebook drives |
| 330 | 330 | await this._migrateToPerNotebookDrives() |
| 331 | 331 | |
| 332 | // Run migration from Hyperdrive media to content-addressed MediaStore |
| 333 | await this._migrateToContentAddressedMedia() |
| 334 | |
| 332 | 335 | // Ensure device keypair exists |
| 333 | 336 | const isNew = await this.identityManager.ensureKeypair() |
| 334 | 337 | if (isNew) { |
| … | … | @@ -370,6 +373,9 @@ class HypernoteServer { |
| 370 | 373 | this.authManager.sharedRegistry = this.multiwriter.registry |
| 371 | 374 | this.noteManager.multiwriter = this.multiwriter |
| 372 | 375 | |
| 376 | // Wire media-gc events from all existing shared notebooks |
| 377 | this._wireNotebookGcEvents() |
| 378 | |
| 373 | 379 | const mwStatus = await this.multiwriter.getStatus() |
| 374 | 380 | const mwKeyHex = mwStatus.registryKey ? (Buffer.isBuffer(mwStatus.registryKey) ? mwStatus.registryKey.toString('hex') : mwStatus.registryKey) : null |
| 375 | 381 | const localWriterKey = this.multiwriter.registry ? this.multiwriter.registry.localWriterKey : null |
| … | … | @@ -582,6 +588,45 @@ class HypernoteServer { |
| 582 | 588 | return false |
| 583 | 589 | } |
| 584 | 590 | |
| 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 | |
| 585 | 630 | /** |
| 586 | 631 | * Migrate from Phase 1-5 single-drive layout to per-notebook drives. |
| 587 | 632 | */ |
| … | … | @@ -632,6 +677,118 @@ class HypernoteServer { |
| 632 | 677 | await this.registry.put('migration:v2-drives', { migratedAt: Date.now(), count: slugs.length }) |
| 633 | 678 | } |
| 634 | 679 | |
| 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 | |
| 635 | 792 | /** |
| 636 | 793 | * Ensure default notebook and profile exist. |
| 637 | 794 | */ |