@@ -404,15 +404,25 @@ const fastForwardDiscussions = async (
404404 let updatedCount = 0 ;
405405 for ( const [ id , updatedDiscussion ] of Object . entries ( fastForwardedDiscussions ) ) {
406406 if ( updatedDiscussion ) {
407- verbose (
408- ` Fast-forwarding discussion ${ id } from ${ discussions [ id ] . currentKey } to ${ targetKey } ` ,
409- ) ;
410407 const compressed = {
411408 ...updatedDiscussion ,
412409 selection : updatedDiscussion . selection
413410 ? compressSelectionJSON ( updatedDiscussion . selection )
414411 : null ,
415412 } ;
413+ // Skip discussions with invalid selections (NaN positions from failed mapping)
414+ if (
415+ compressed . selection &&
416+ ( Number . isNaN ( compressed . selection . a ) || Number . isNaN ( compressed . selection . h ) )
417+ ) {
418+ verbose (
419+ ` Skipping discussion ${ id } - invalid selection after fast-forward (NaN position)` ,
420+ ) ;
421+ continue ;
422+ }
423+ verbose (
424+ ` Fast-forwarding discussion ${ id } from ${ discussions [ id ] . currentKey } to ${ targetKey } ` ,
425+ ) ;
416426 updates [ id ] = compressed ;
417427 updatedCount ++ ;
418428 }
@@ -580,9 +590,13 @@ const pruneDraft = async (
580590 // Check if this is a corrupted history error
581591 // - "Position X out of range" = missing/corrupted steps
582592 // - "Invalid content for node doc" = reconstructed doc is empty/malformed
593+ // - "Inconsistent open depths" = malformed ProseMirror steps
594+ // - "Cannot read properties of null" = null doc from failed reconstruction
583595 const isCorruptedHistory =
584596 ( errorStr . includes ( 'Position' ) && errorStr . includes ( 'out of range' ) ) ||
585- errorStr . includes ( 'Invalid content for node' ) ;
597+ errorStr . includes ( 'Invalid content for node' ) ||
598+ errorStr . includes ( 'Inconsistent open depths' ) ||
599+ errorStr . includes ( 'Cannot read properties of null' ) ;
586600
587601 if ( isCorruptedHistory && pubId ) {
588602 // Try to repair by creating a checkpoint from a Release
@@ -631,39 +645,32 @@ const pruneDraft = async (
631645 const discussionsUpdated = await fastForwardDiscussions ( draftRef , pruneThreshold ) ;
632646 localStats . discussionsUpdated += discussionsUpdated ;
633647
634- // Prune changes before the safe threshold
635- localStats . changesDeleted += await pruneKeysBefore ( draftRef , 'changes' , pruneThreshold ) ;
636-
637- // Prune merges before the safe threshold (legacy data)
638- localStats . mergesDeleted += await pruneKeysBefore ( draftRef , 'merges' , pruneThreshold ) ;
639-
640- // Prune checkpoints before the prune threshold (keep threshold checkpoint and any after)
641- localStats . checkpointsDeleted += await pruneKeysBefore ( draftRef , 'checkpoints' , pruneThreshold ) ;
648+ // Prune changes, merges, and checkpoints in parallel (they're independent)
649+ const [ changesDeleted , mergesDeleted , checkpointsDeleted ] = await Promise . all ( [
650+ pruneKeysBefore ( draftRef , 'changes' , pruneThreshold ) ,
651+ pruneKeysBefore ( draftRef , 'merges' , pruneThreshold ) ,
652+ pruneKeysBefore ( draftRef , 'checkpoints' , pruneThreshold ) ,
653+ ] ) ;
654+ localStats . changesDeleted += changesDeleted ;
655+ localStats . mergesDeleted += mergesDeleted ;
656+ localStats . checkpointsDeleted += checkpointsDeleted ;
642657
643658 // Clean up checkpointMap entries for deleted checkpoints
644- const checkpointMapSnapshot = await draftRef . child ( 'checkpointMap' ) . once ( 'value' ) ;
645- const checkpointMap = checkpointMapSnapshot . val ( ) ;
646- if ( checkpointMap ) {
647- const oldMapKeys = Object . keys ( checkpointMap ) . filter (
648- ( k ) => parseInt ( k , 10 ) < pruneThreshold ,
649- ) ;
650- if ( oldMapKeys . length > 0 && ! isDryRun ) {
651- // Use batch update for faster deletion
652- const updates : Record < string , null > = { } ;
653- for ( const key of oldMapKeys ) {
654- updates [ key ] = null ;
655- }
656- await draftRef . child ( 'checkpointMap' ) . update ( updates ) ;
657- verbose ( `${ prefix } Cleaned up ${ oldMapKeys . length } checkpointMap entries` ) ;
659+ // Reuse checkpointKeys we already fetched earlier
660+ const oldMapKeys = checkpointKeys . filter ( ( k ) => k < pruneThreshold ) . map ( String ) ;
661+ if ( oldMapKeys . length > 0 && ! isDryRun ) {
662+ const updates : Record < string , null > = { } ;
663+ for ( const key of oldMapKeys ) {
664+ updates [ key ] = null ;
658665 }
666+ await draftRef . child ( 'checkpointMap' ) . update ( updates ) ;
667+ verbose ( `${ prefix } Cleaned up ${ oldMapKeys . length } checkpointMap entries` ) ;
659668 }
660669
661- // Remove deprecated singular checkpoint if we have checkpoints/ entries
662- if ( ! isDryRun ) {
663- const hasCheckpoints = ( await draftRef . child ( 'checkpoints' ) . once ( 'value' ) ) . exists ( ) ;
664- if ( hasCheckpoints ) {
665- await draftRef . child ( 'checkpoint' ) . remove ( ) ;
666- }
670+ // Remove deprecated singular checkpoint if we deleted any checkpoints
671+ // (avoiding extra read - if we pruned checkpoints, the checkpoints/ path exists)
672+ if ( ! isDryRun && checkpointsDeleted > 0 ) {
673+ await draftRef . child ( 'checkpoint' ) . remove ( ) ;
667674 }
668675} ;
669676
@@ -784,13 +791,13 @@ const cleanupOrphanedFirebasePaths = async (): Promise<void> => {
784791 }
785792
786793 fetched ++ ;
787- if ( fetched % 500 === 0 ) {
794+ if ( fetched % 1000 === 0 ) {
788795 log ( ` Scanned ${ fetched } /${ legacyPubKeys . length } legacy pubs...` ) ;
789796 }
790797
791798 return { pubKey, childKeys, orphanedBranches, hasValidBranch } ;
792799 } ) ,
793- 20 ,
800+ 50 ,
794801 ) ;
795802
796803 // Collect all paths to delete
0 commit comments